diff -Nru old/build.gradle new/build.gradle
--- old/build.gradle	2026-09-25 09:27:23.798728181 +0300
+++ new/build.gradle	2026-09-25 09:27:23.844791001 +0300
@@ -555,7 +555,7 @@
     from "${layout.buildDirectory.get()}/classes"
     into "${layout.buildDirectory.get()}/classes.aop"
     includeEmptyDirs = false
-    dependsOn 'classes', 'copyResources'
+    dependsOn 'classes', 'copyResources', 'createCompileClasspathJar'
 }
 
 // Copy tools/scripts to build/tools/scripts/ (these go outside classes/)
@@ -786,6 +786,213 @@
 }
 
 // ============================================================================
+// Ahead-of-time generation of the expression classes of the shipped rule sets.
+//
+// A conversion otherwise generates, defines and JIT compiles a class for every expression of every
+// rule set it loads, on every run.  Those rule sets are fixed content shipped with FWD, so the work
+// is the same every time and is done here instead, once, and packaged into p2j.jar.
+//
+// Up-to-date checking is what keeps this off the critical path of an ordinary build:  the task
+// reruns only when a rule file changes, or when the expression compiler which generates the classes
+// changes.  The latter matters because a change to code generation alters what the correct bytecode
+// is, and nothing at run time would detect a class generated by an older compiler.
+// ============================================================================
+// NOT yet wired into 'jar':  the generator currently produces an empty index, because compiling a
+// rule set's expressions outside a running conversion does not resolve the pattern worker
+// namespaces (see AotWarmer).  Wiring it in would add minutes to every build for no benefit.
+tasks.register('generateAotExpressions', JavaExec) {
+    description = 'Generate the expression classes of the shipped rule sets (experimental)'
+    group = 'build'
+
+    dependsOn 'classes', 'copyResources'
+
+    def aotDir = layout.buildDirectory.dir('aot')
+
+    def aotLevel = (project.findProperty('aotDebugLevel') ?: '0').toString()
+
+    inputs.files(fileTree('rules'))
+          .withPropertyName('ruleSets')
+          .withPathSensitivity(PathSensitivity.RELATIVE)
+    inputs.files(fileTree('src/com/goldencode/expr'))
+          .withPropertyName('expressionCompiler')
+          .withPathSensitivity(PathSensitivity.RELATIVE)
+    // the level decides what the compiler emits, so it has to invalidate the output like any
+    // other input;  without this, changing it would leave the previous level's classes in place
+    inputs.property('debugLevel', aotLevel)
+    outputs.dir(aotDir).withPropertyName('generatedClasses')
+
+    // The generator loads every profile, and the pattern engine has plenty to say while doing so
+    // -- none of it about this task.  Capture it all and surface only the one summary line, unless
+    // the build is run with --info or the generator fails, in which case show everything.
+    def captured = new ByteArrayOutputStream()
+
+    standardOutput = captured
+    errorOutput = captured
+    ignoreExitValue = true
+
+    mainClass = 'com.goldencode.p2j.pattern.AotWarmer'
+    // compileJava writes to build/classes, and the dependency set is reached through the manifest
+    // classpath jar the build already assembles for exactly this purpose
+    classpath = files("${layout.buildDirectory.get()}/classes",
+                      "${layout.buildDirectory.get()}/compile-classpath.jar")
+                   .builtBy('classes', 'copyResources', 'createCompileClasspathJar')
+    maxHeapSize = '4g'
+
+    doFirst {
+        // 0..3 only:  see DebugLevels.  A value outside that range silently falls through to no
+        // runtime flags, producing level 0 classes labelled as something else, which is worse than
+        // a failure.
+        if (!(aotLevel ==~ /[0-3]/)) {
+            throw new GradleException(
+                "aotDebugLevel must be 0..3 (none, status, debug, trace), got '${aotLevel}'")
+        }
+
+        // The pattern engine expects a project home with a cfg/ directory.  Generation needs none
+        // of what a real project configures -- no schema, no file set, no output root -- because
+        // rule set variables carry explicit Java types and pattern worker libraries are plain Java
+        // classes.  So a minimal home is synthesized here, pointing only at the rules to load.
+        def aotHome = layout.buildDirectory.dir('aot-home').get().asFile
+        def cfgDir = new File(aotHome, 'cfg')
+
+        cfgDir.mkdirs()
+
+        new File(cfgDir, 'p2j.cfg.xml').text = """<?xml version="1.0"?>
+<!-- Generated by the generateAotExpressions task; not a project configuration. -->
+<cfg>
+   <global>
+      <parameter name="P2J_RULES" value="${projectDir.path}/rules" />
+      <parameter name="patpath"   value=".:\${P2J_RULES}/include:\${P2J_RULES}:" />
+   </global>
+   <profile name="default" default="true">
+      <file-set/>
+   </profile>
+   <schema>
+      <metadata name="standard"/>
+   </schema>
+</cfg>
+"""
+
+        workingDir = aotHome
+        systemProperty 'P2J_HOME', aotHome.path
+
+        // Must match the Dn debug level conversions run at (see the -SIdN argument in a project's
+        // build.xml).  Override with -PaotDebugLevel=N.
+        systemProperty 'p2j.aot.debug-level', aotLevel
+        systemProperty 'p2j.aot.verbose', (project.findProperty('aotVerbose') ?: 'false')
+
+        // every profile in the rules tree;  one which cannot be loaded is reported and skipped,
+        // and its expressions simply keep being compiled at conversion time
+        // Every profile in the rules tree except the call graph ones, which are a separate
+        // pipeline and bring up their JanusGraph/BerkeleyDB store merely to be loaded.
+        //
+        // Resist pruning further by directory name.  The profiles under runtime/ are loaded by
+        // the *server*, not by a conversion -- it converts dynamic queries through this same
+        // pattern engine, see ConversionProfile in the persist package -- and dropping them on
+        // the assumption that "runtime" meant something else silently cost the server its
+        // generated classes.  progress/ and reports/ are likewise reached, from
+        // ProgressTransformDriver and ReportDriver.  A profile which fails to load is reported
+        // and skipped anyway, so guessing which ones matter buys nothing.
+        def excluded = ['callgraph/']
+
+        def profiles = fileTree('rules') { include '**/*.xml' }
+            .files
+            .collect { file(it).path - (projectDir.path + '/rules/') - '.xml' }
+            .findAll { name -> !excluded.any { name.startsWith(it) } }
+            .sort()
+
+        logger.info("generateAotExpressions: ${profiles.size()} profiles")
+
+        args = [aotDir.get().asFile.path] + profiles
+    }
+
+    doLast {
+        def text = captured.toString()
+
+        if (executionResult.get().exitValue != 0) {
+            logger.error(text)
+
+            throw new GradleException('generateAotExpressions failed')
+        }
+
+        if (logger.isInfoEnabled()) {
+            logger.lifecycle(text.trim())
+        }
+        else {
+            text.readLines()
+                .findAll { it.startsWith('AOT expressions:') }
+                .each { logger.lifecycle(it) }
+        }
+    }
+}
+
+// Identity of the inputs the generated expression classes were produced from.  The same value is
+// written into both jars;  a conversion compares them and ignores the generated classes unless
+// they agree, so a fwd-trpl.jar left behind from an older build is inert rather than wrong.
+def aotStampValue = {
+    def digest = java.security.MessageDigest.getInstance('SHA-256')
+    def sources = []
+
+    fileTree('rules').files.each { sources << it }
+    fileTree('src/com/goldencode/expr').files.each { sources << it }
+
+    sources.sort { it.path }.each { f ->
+        digest.update((f.path - projectDir.path).getBytes('UTF-8'))
+        digest.update(java.security.MessageDigest.getInstance('SHA-256').digest(f.bytes))
+    }
+
+    return digest.digest().encodeHex().toString().substring(0, 32)
+}
+
+tasks.register('aotStamp') {
+    description = 'Compute the identity shared by p2j.jar and fwd-trpl.jar'
+
+    // part of src/com/goldencode/expr is generated, so the generators have to have run before
+    // its content can be hashed
+    dependsOn 'classes'
+
+    inputs.files(fileTree('rules')).withPropertyName('ruleSets')
+    inputs.files(fileTree('src/com/goldencode/expr')).withPropertyName('expressionCompiler')
+    outputs.dir(layout.buildDirectory.dir('aot-stamp')).withPropertyName('stamp')
+
+    doLast {
+        def value = aotStampValue()
+
+        // the copy inside p2j.jar: what a conversion expects the generated classes to have been
+        // built from
+        def expected = layout.buildDirectory.file('aot-stamp/com/goldencode/expr/aot-expected.txt')
+                             .get().asFile
+
+        expected.parentFile.mkdirs()
+        expected.text = value + '\n'
+
+        logger.info("aotStamp: ${value}")
+    }
+}
+
+// The generated expression classes, packaged apart from p2j.jar.  Separate because there are tens
+// of thousands of them:  folding them into p2j.jar pushed it past the 64k zip entry limit, and it
+// keeps an optional, regenerable artifact out of the one that has to be right.
+tasks.register('trplJar', Jar) {
+    dependsOn 'generateAotExpressions', 'aotStamp'
+    archiveFileName = 'fwd-trpl.jar'
+    destinationDirectory = file("${layout.buildDirectory.get()}/lib")
+
+    from("${layout.buildDirectory.get()}/aot") {
+        include 'com/goldencode/expr/aot/**'
+    }
+
+    doFirst {
+        // the copy inside fwd-trpl.jar: what these classes were actually built from
+        def stampFile = layout.buildDirectory.file('aot/com/goldencode/expr/aot/stamp.txt')
+                              .get().asFile
+
+        stampFile.parentFile.mkdirs()
+        stampFile.text = aotStampValue() + '\n'
+    }
+}
+
+
+// ============================================================================
 // JAR creation
 // Three JARs from build/classes.aop/: p2j.jar, fwdspi.jar, fwdaopltw.jar
 // Plus p2jpl.jar for PL/Java
@@ -796,7 +1003,8 @@
     dependsOn 'prepareClassesAop', 'makeManifests', 'copyDependencies',
               'mergePostgreSQLUDFs', 'mergeMariaDBUDFs', 'mergeSqlServerUDFs',
               'copyToolsScripts', 'fwdspiJar', 'fwdaopltwJar',
-              'createCompileClasspathJar', 'createTestsClasspathJar'
+              'createCompileClasspathJar', 'createTestsClasspathJar',
+              'trplJar', 'aotStamp'
     archiveFileName = 'p2j.jar'
     destinationDirectory = file("${layout.buildDirectory.get()}/lib")
     from("${layout.buildDirectory.get()}/classes.aop") {
@@ -824,6 +1032,11 @@
     from("${layout.buildDirectory.get()}/admin") {
         include 'com/goldencode/p2j/admin/**'
     }
+    // Only the expected stamp;  the generated classes themselves live in fwd-trpl.jar.
+    from("${layout.buildDirectory.get()}/aot-stamp") {
+        include 'com/goldencode/expr/aot-expected.txt'
+    }
+
     // Include the empty SPI directory to match reference
     from("${layout.buildDirectory.get()}/classes.aop") {
         include 'com/goldencode/p2j/spi/'
@@ -854,7 +1067,12 @@
             attributes(
                 'Implementation-Version': ant.properties['fwdver'],
                 'Main-Class': 'com.goldencode.p2j.Version',
-                'Class-Path': configurations.fwdAllRuntime.collect { it.name }.join(' ')
+                // fwd-trpl.jar carries the expression classes generated from the shipped rule
+                // sets.  Referenced here so that a project picks it up without touching its own
+                // build, and optional at run time:  if it is absent or stale, expressions are
+                // compiled exactly as they always were.
+                'Class-Path': (['fwd-trpl.jar'] +
+                               configurations.fwdAllRuntime.collect { it.name }).join(' ')
             )
         }
     }
@@ -1097,7 +1315,9 @@
     destinationDirectory = file(distDir)
     from("${layout.buildDirectory.get()}/lib") {
         into 'lib'
-        include 'p2j.jar', 'fwdaopltw.jar', 'fwd_sheet.war', libname
+        // fwd-trpl.jar is needed here as well as for conversion:  the server converts dynamic
+        // queries at runtime through the same pattern engine (see ConversionProfile).
+        include 'p2j.jar', 'fwdaopltw.jar', 'fwd-trpl.jar', 'fwd_sheet.war', libname
     }
     from("${layout.buildDirectory.get()}/lib") {
         into 'lib/spi'
diff -Nru old/src/com/goldencode/expr/AotGenerator.java new/src/com/goldencode/expr/AotGenerator.java
--- old/src/com/goldencode/expr/AotGenerator.java	1970-01-01 02:00:00.000000000 +0200
+++ new/src/com/goldencode/expr/AotGenerator.java	2026-09-25 09:27:23.846000306 +0300
@@ -0,0 +1,398 @@
+/*
+** Module   : AotGenerator.java
+** Abstract : Build time generation of the expression classes of the shipped rule sets
+**
+** Copyright (c) 2026, Golden Code Development Corporation.
+**
+** -#- -I- --Date-- ----------------Description-----------------
+** 001 TG  20260909 Initial version.
+*/
+
+package com.goldencode.expr;
+
+import java.io.*;
+import java.nio.charset.*;
+import java.nio.file.*;
+import java.security.*;
+import java.util.*;
+
+/**
+ * Collects the expression classes of the shipped rule sets at build time, so that a conversion can
+ * load them instead of generating them again on every run.
+ *
+ * <h3>How generation is driven</h3>
+ *
+ * This class does not walk the rule sets itself.  It switches the compiler into generation mode and
+ * lets the normal machinery run:  a driver loads each profile and forces every expression in it to
+ * compile, and each resulting class arrives here through {@link #emit}.  That keeps one code path
+ * for producing a class, which is what makes a generated class trustworthy -- there is no second
+ * implementation to drift from the first.
+ *
+ * <h3>Naming and sharing</h3>
+ *
+ * Classes are named from {@link Compiler#signature}, a description of the compilation which is
+ * available before any code is assembled and which is verified to determine the emitted bytecode
+ * exactly.  Occurrences whose compilations are interchangeable therefore land on one name and are
+ * emitted once, which is why the class count is well below the occurrence count.
+ * <p>
+ * The names live in their own package, so they can never collide with the sequentially named
+ * classes an ordinary compilation produces.
+ *
+ * @see AotIndex
+ */
+public final class AotGenerator
+{
+   /** Package holding every generated class. */
+   static final String PACKAGE = AotIndex.PACKAGE;
+
+   /** Flag indicating the compiler should name and emit classes for the index. */
+   private static volatile boolean generating = false;
+
+   /** Emitted classes, keyed by fully qualified name, so a shared class is written once. */
+   private static final Map<String, byte[]> classes = new TreeMap<>();
+
+   /** Index entries, keyed by expression occurrence name. */
+   private static final Map<String, String> index = new TreeMap<>();
+
+   /** Number of compilations which reached this collector. */
+   private static int compiled = 0;
+
+   /** Number of those which had no durable name and so could not be indexed. */
+   private static int unnamed = 0;
+
+   /** Number of class files actually written, after folding byte identical ones together. */
+   private static int classesWritten = 0;
+
+   /**
+    * Report whether the compiler is running in generation mode.
+    *
+    * @return  <code>true</code> while a generation run is in progress.
+    */
+   static boolean isGenerating()
+   {
+      return generating;
+   }
+
+   /**
+    * Turn generation mode on or off.
+    *
+    * @param   on
+    *          <code>true</code> to start generating.
+    */
+   public static void setGenerating(boolean on)
+   {
+      generating = on;
+   }
+
+   /**
+    * Compose the class name for a compilation, in the internal, slash separated form which
+    * {@link ClassFile} requires and which is embedded in the generated constant pool.
+    *
+    * @param   signature
+    *          The compilation's signature, from {@link Compiler#signature}.
+    *
+    * @return  Internal form of the class name.
+    */
+   static String internalName(String signature)
+   {
+      return (PACKAGE + "A" + AotIndex.digest(signature)).replace('.', '/');
+   }
+
+   /**
+    * Accept a class the compiler has just assembled, together with the state a conversion needs in
+    * order to bind it to a caller's scope without parsing the expression again.
+    *
+    * @param   expression
+    *          Expression the class was generated for.
+    * @param   classFile
+    *          The assembled class.
+    * @param   returnType
+    *          Return type the blueprint must be configured with.
+    * @param   libraries
+    *          Library symbol to slot map.
+    * @param   variables
+    *          Variable symbol to slot map.
+    */
+   static synchronized void emit(Expression expression, ClassFile classFile, Class<?> returnType,
+                                 Map<String, Integer> libraries, Map<String, Integer> variables)
+   {
+      String aotId = expression.getAotId();
+
+      compiled++;
+
+      if (aotId == null)
+      {
+         // not part of a rule set, so no conversion will ever look for it
+         unnamed++;
+
+         return;
+      }
+
+      String className = classFile.getFullyQualifiedName();
+
+      try
+      {
+         if (!classes.containsKey(className))
+         {
+            ByteArrayOutputStream baos = new ByteArrayOutputStream(4096);
+            DataOutputStream dos = new DataOutputStream(baos);
+
+            classFile.write(dos);
+            dos.flush();
+
+            classes.put(className, baos.toByteArray());
+         }
+
+         index.put(aotId,
+                   className.substring(PACKAGE.length()) + '|' +
+                   expression.getRuntimeFlags() + '|' +
+                   AotIndex.digest(expression.getInfix()) + '|' +
+                   ((returnType == null) ? "null" : returnType.getName()) + '|' +
+                   String.join(",", slotNames(libraries)) + '|' +
+                   String.join(",", slotNames(variables)));
+      }
+      catch (IOException exc)
+      {
+         throw new IllegalStateException("could not serialize " + className, exc);
+      }
+   }
+
+   /**
+    * Write everything collected so far:  one class file per generated class, laid out by package so
+    * that the build can fold them straight into the artifact, plus the index which maps occurrences
+    * onto them.
+    *
+    * @param   outputDir
+    *          Root directory to write into.
+    * @param   debugLevel
+    *          Debug level these classes were generated for.  Recorded so that a conversion running
+    *          at a different level can say so precisely instead of just finding nothing.
+    *
+    * @throws  IOException
+    *          if writing fails.
+    */
+   public static synchronized void write(File outputDir, int debugLevel)
+   throws IOException
+   {
+      File classDir = new File(outputDir, PACKAGE.replace('.', '/'));
+
+      classDir.mkdirs();
+
+      // Names come from the compilation signature, which is deliberately over-inclusive:  it
+      // separates compilations which would in fact assemble to identical bytecode.  Fold those
+      // together now, by fingerprinting each class with its own name masked out, so that the
+      // artifact carries one copy and a conversion loads and verifies one class instead of
+      // several.  Class loading is the dominant cost of using these at run time, so this is worth
+      // more than the disk space it saves.
+      Map<String, String> canonical = new HashMap<>(classes.size());
+      Map<String, String> alias = new HashMap<>(classes.size());
+
+      for (Map.Entry<String, byte[]> next : classes.entrySet())
+      {
+         String name = next.getKey();
+         String print = fingerprint(next.getValue(), name.replace('.', '/'));
+         String first = canonical.putIfAbsent(print, name);
+
+         alias.put(name, (first == null) ? name : first);
+      }
+
+      int written = 0;
+
+      for (Map.Entry<String, byte[]> next : classes.entrySet())
+      {
+         String name = next.getKey();
+
+         if (!name.equals(alias.get(name)))
+         {
+            // a duplicate of one already being written
+            continue;
+         }
+
+         Files.write(new File(classDir, name.substring(PACKAGE.length()) + ".class").toPath(),
+                     next.getValue());
+         written++;
+      }
+
+      // point every occurrence at the surviving copy of its class
+      for (Map.Entry<String, String> next : index.entrySet())
+      {
+         String value = next.getValue();
+         int bar = value.indexOf('|');
+         String canon = alias.get(PACKAGE + value.substring(0, bar));
+
+         if (canon != null)
+         {
+            next.setValue(canon.substring(PACKAGE.length()) + value.substring(bar));
+         }
+      }
+
+      // One index per profile.  A conversion loads profiles one at a time, so this keeps both the
+      // parse cost and the resident map proportional to the profile in hand rather than to the
+      // whole rule base -- which is nearly half a million occurrences.
+      File indexDir = new File(classDir, "index");
+
+      indexDir.mkdirs();
+
+      Map<String, List<String>> byProfile = new TreeMap<>();
+
+      for (Map.Entry<String, String> next : index.entrySet())
+      {
+         String aotId = next.getKey();
+         int hash = aotId.lastIndexOf('#');
+         String profile = aotId.substring(0, hash);
+
+         byProfile.computeIfAbsent(profile, k -> new ArrayList<>())
+                  .add(aotId.substring(hash + 1) + '|' + next.getValue());
+      }
+
+      for (Map.Entry<String, List<String>> next : byProfile.entrySet())
+      {
+         File target = new File(indexDir, AotIndex.indexFileName(next.getKey()));
+
+         try (PrintWriter out = new PrintWriter(
+                 new OutputStreamWriter(
+                    new BufferedOutputStream(new FileOutputStream(target)),
+                    StandardCharsets.UTF_8)))
+         {
+            out.println(AotIndex.MAGIC);
+
+            for (String line : next.getValue())
+            {
+               out.println(line);
+            }
+         }
+      }
+
+      // what these classes were generated for, so a mismatch can be reported precisely
+      Files.write(new File(classDir, "meta.txt").toPath(),
+                  ("debug-level=" + debugLevel + "\n").getBytes(StandardCharsets.UTF_8));
+
+      classesWritten = written;
+   }
+
+   /**
+    * Report how many occurrences have been indexed.
+    *
+    * @return  Occurrence count.
+    */
+   public static synchronized int occurrences()
+   {
+      return index.size();
+   }
+
+   /**
+    * Report how many distinct classes have been generated.
+    *
+    * @return  Class count.
+    */
+   public static synchronized int classCount()
+   {
+      return (classesWritten == 0) ? classes.size() : classesWritten;
+   }
+
+   /**
+    * Fingerprint class file bytes, ignoring every occurrence of the class's own internal name.
+    * <p>
+    * That name is the one part which differs between two otherwise identical classes, and it is a
+    * constant pool UTF8 entry preceded by its own two byte length, so both are dropped.
+    *
+    * @param   raw
+    *          Class file bytes.
+    * @param   internalName
+    *          The class's own name in internal form.
+    *
+    * @return  Hex encoded digest of the name independent content.
+    */
+   private static String fingerprint(byte[] raw, String internalName)
+   {
+      try
+      {
+         byte[] needle = internalName.getBytes(StandardCharsets.UTF_8);
+         MessageDigest digest = MessageDigest.getInstance("SHA-256");
+         int pos = 0;
+
+         for (int i = 0; i <= raw.length - needle.length; )
+         {
+            if (matches(raw, i, needle))
+            {
+               digest.update(raw, pos, Math.max(pos, i - 2) - pos);
+
+               i += needle.length;
+               pos = i;
+            }
+            else
+            {
+               i++;
+            }
+         }
+
+         digest.update(raw, pos, raw.length - pos);
+
+         StringBuilder buf = new StringBuilder(64);
+
+         for (byte b : digest.digest())
+         {
+            buf.append(Character.forDigit((b >> 4) & 0xf, 16));
+            buf.append(Character.forDigit(b & 0xf, 16));
+         }
+
+         return buf.toString();
+      }
+      catch (NoSuchAlgorithmException exc)
+      {
+         throw new IllegalStateException(exc);
+      }
+   }
+
+   /**
+    * Test whether <code>needle</code> occurs in <code>raw</code> at <code>at</code>.
+    *
+    * @param   raw
+    *          Buffer to search.
+    * @param   at
+    *          Offset at which to compare.
+    * @param   needle
+    *          Bytes to look for.
+    *
+    * @return  <code>true</code> on a match.
+    */
+   private static boolean matches(byte[] raw, int at, byte[] needle)
+   {
+      for (int i = 0; i < needle.length; i++)
+      {
+         if (raw[at + i] != needle[i])
+         {
+            return false;
+         }
+      }
+
+      return true;
+   }
+
+   /**
+    * Render a symbol to slot map as a list ordered by slot.
+    *
+    * @param   slots
+    *          Map of symbol name to slot index.
+    *
+    * @return  Symbol names in slot order.
+    */
+   private static List<String> slotNames(Map<String, Integer> slots)
+   {
+      String[] byIndex = new String[slots.size()];
+
+      for (Map.Entry<String, Integer> next : slots.entrySet())
+      {
+         byIndex[next.getValue()] = next.getKey();
+      }
+
+      return Arrays.asList(byIndex);
+   }
+
+   /**
+    * Not instantiable.
+    */
+   private AotGenerator()
+   {
+   }
+}
diff -Nru old/src/com/goldencode/expr/AotIndex.java new/src/com/goldencode/expr/AotIndex.java
--- old/src/com/goldencode/expr/AotIndex.java	1970-01-01 02:00:00.000000000 +0200
+++ new/src/com/goldencode/expr/AotIndex.java	2026-09-25 09:27:23.846124763 +0300
@@ -0,0 +1,708 @@
+/*
+** Module   : AotIndex.java
+** Abstract : Index of expression classes generated when FWD itself was built
+**
+** Copyright (c) 2026, Golden Code Development Corporation.
+**
+** -#- -I- --Date-- ----------------Description-----------------
+** 001 TG  20260909 Initial version.
+*/
+
+package com.goldencode.expr;
+
+import java.io.*;
+import java.nio.charset.*;
+import java.security.*;
+import java.util.*;
+
+/**
+ * Index of the expression classes which were generated when FWD was built, and which ship inside
+ * the same artifact as the rule sets they came from.
+ *
+ * <h3>What this replaces</h3>
+ *
+ * Left to itself a conversion generates, defines and JIT compiles a class for every expression of
+ * every rule set it loads, on every run.  Those rule sets are fixed content shipped with FWD, so
+ * the work is the same every time and can be done once, at build time.  A run which finds an
+ * occurrence here loads an ordinary class from the classpath instead.
+ *
+ * <h3>How an occurrence is named</h3>
+ *
+ * Every expression belonging to a rule set is created during a profile load, which reads a fixed
+ * set of files in document order on a single thread, so each occurrence has a stable position in
+ * that sequence.  {@link Expression#assignAotId} turns that into a durable name, and this index is
+ * keyed on it.  Because the index and the rule sets are produced by one build and shipped in one
+ * artifact, they cannot disagree about that sequence.
+ *
+ * <h3>What an entry holds beyond the class</h3>
+ *
+ * The class alone is not enough:  {@link Compiler#instantiate} binds a compiled expression to the
+ * libraries and variables of the calling scope through the symbol-to-slot maps that parsing
+ * produced, and the blueprint needs its return type.  Recording those is what removes parsing from
+ * the fast path.
+ *
+ * <h3>Failing safe</h3>
+ *
+ * Every entry carries a digest of the expression text it was generated from, checked before the
+ * class is used.  In a released build that check can never fail;  it exists for the developer case
+ * where rules have been edited without regenerating, and it turns what would be a wrong class into
+ * an ordinary compilation.  A missing index, an unreadable entry, an absent class -- all degrade to
+ * compiling, never to misbehaving.
+ */
+final class AotIndex
+{
+   /** Package holding every generated class. */
+   static final String PACKAGE = "com.goldencode.expr.aot.";
+
+   /** Resource directory holding the per profile indexes, relative to the classpath root. */
+   static final String RESOURCE_DIR = "com/goldencode/expr/aot/index/";
+
+   /** Identity of the inputs the generated classes were built from, inside fwd-trpl.jar. */
+   static final String STAMP_RESOURCE = "com/goldencode/expr/aot/stamp.txt";
+
+   /** Identity those classes are expected to have, inside p2j.jar. */
+   static final String EXPECTED_RESOURCE = "com/goldencode/expr/aot-expected.txt";
+
+   /** What the generated classes were built for, inside fwd-trpl.jar. */
+   static final String META_RESOURCE = "com/goldencode/expr/aot/meta.txt";
+
+   /** Format marker of the index. */
+   static final String MAGIC = "#fwd-aot 2";
+
+   /** Occurrences served from the index. */
+   private static int hits = 0;
+
+   /** Occurrences which had to be compiled. */
+   private static int misses = 0;
+
+   /** Occurrences compiled because they belong to no profile -- expression text built at run time. */
+   private static int unnamedMisses = 0;
+
+   /** Hits and misses per profile, so a shortfall can be attributed rather than guessed at. */
+   private static final Map<String, int[]> byProfile = new TreeMap<>();
+
+   /** Entries of the profile currently loaded, keyed by occurrence ordinal. */
+   private static volatile Map<Integer, Entry> entries = Collections.emptyMap();
+
+   /** Profile whose entries are resident, or <code>null</code>. */
+   private static volatile String resident = null;
+
+   /**
+    * Flag disabling use of the generated classes, so a conversion compiles expressions exactly as
+    * it would without them.  Set <code>P2J_AOT_DISABLE</code> in the environment.  Kept as an
+    * escape hatch:  if a generated class is ever suspected, this turns the mechanism off without
+    * rebuilding or repackaging anything.
+    */
+   private static final boolean DISABLED = (System.getenv("P2J_AOT_DISABLE") != null);
+
+   /**
+    * The artifact holding the generated classes -- fwd-trpl.jar -- or <code>null</code> when it is
+    * absent, unreadable, or does not match this build.
+    * <p>
+    * It is located once, by asking the classpath for its stamp, and thereafter read directly.  The
+    * conversion classpath is several hundred jars, so searching it per class is what this avoids.
+    */
+   private static final Object SOURCE = openSource();
+
+   /** Loader which resolves the generated classes straight out of {@link #SOURCE}. */
+   private static final AotClassLoader LOADER = new AotClassLoader(AotIndex.class.getClassLoader());
+
+   /**
+    * Not instantiable.
+    */
+   private AotIndex()
+   {
+   }
+
+   /**
+    * Compose the resource name holding a profile's index.  Profile names contain path separators,
+    * which are folded so that every index is one flat resource.
+    *
+    * @param   profile
+    *          Profile name.
+    *
+    * @return  Resource file name.
+    */
+   public static String indexFileName(String profile)
+   {
+      return profile.replace('/', '.') + ".txt";
+   }
+
+   static
+   {
+      Runtime.getRuntime().addShutdownHook(new Thread(AotIndex::report));
+   }
+
+   /**
+    * Describe what the generated classes were built for.
+    *
+    * @return  A short description, for reporting.
+    */
+   private static String describeGeneratedFor()
+   {
+      String meta = text(bytesOf(META_RESOURCE));
+
+      return (meta == null) ? "an unrecorded debug level" : meta.replace('=', ' ');
+   }
+
+   /**
+    * Report whether any entry is currently resident.
+    *
+    * @return  <code>true</code> if a lookup could succeed.
+    */
+   static boolean isAvailable()
+   {
+      return !entries.isEmpty();
+   }
+
+   /**
+    * Make the generated entries of a profile resident, replacing whichever profile was resident
+    * before.  Called as a profile's rule sets begin loading.
+    *
+    * @param   profile
+    *          Profile being loaded, or <code>null</code> to drop what is resident.
+    */
+   static synchronized void selectProfile(String profile)
+   {
+      if (DISABLED || profile == null || profile.equals(resident))
+      {
+         return;
+      }
+
+      resident = profile;
+      entries = read(profile);
+   }
+
+   /**
+    * Load one of the generated classes.
+    *
+    * @param   className
+    *          Fully qualified name of the class.
+    *
+    * @return  The class.
+    *
+    * @throws  ClassNotFoundException
+    *          if it is not present.
+    */
+   static Class<?> loadClass(String className)
+   throws ClassNotFoundException
+   {
+      return LOADER.loadClass(className, true);
+   }
+
+   /**
+    * Note that an occurrence was served from the index.
+    */
+   static synchronized void hit()
+   {
+      hits++;
+      tally(resident, 0);
+   }
+
+   /**
+    * Note that an occurrence had to be compiled.
+    */
+   static synchronized void miss()
+   {
+      misses++;
+      tally(resident, 1);
+   }
+
+   /**
+    * Note that an occurrence had to be compiled because it has no durable name at all, which means
+    * its text was assembled at run time rather than read from a rule set.  Nothing can be
+    * generated for those.
+    */
+   static synchronized void unnamedMiss()
+   {
+      misses++;
+      unnamedMisses++;
+   }
+
+   /**
+    * Record an outcome against the profile currently loaded.
+    *
+    * @param   profile
+    *          Profile in force, or <code>null</code>.
+    * @param   slot
+    *          0 for a hit, 1 for a miss.
+    */
+   private static void tally(String profile, int slot)
+   {
+      byProfile.computeIfAbsent((profile == null) ? "<none>" : profile, k -> new int[2])[slot]++;
+   }
+
+   /**
+    * Look up the class generated for an expression occurrence.
+    *
+    * @param   aotId
+    *          Durable name of the occurrence, or <code>null</code> if it has none.
+    * @param   infix
+    *          The occurrence's expression text, checked against the text the entry was generated
+    *          from.
+    * @param   flags
+    *          The occurrence's runtime flags, likewise checked.
+    *
+    * @return  The entry, or <code>null</code> if there is none or it does not describe this
+    *          expression, in which case the caller compiles as usual.
+    */
+   static Entry lookup(String aotId, String infix, int flags)
+   {
+      if (aotId == null || entries.isEmpty())
+      {
+         return null;
+      }
+
+      int hash = aotId.lastIndexOf('#');
+
+      if (hash < 0 || !aotId.regionMatches(0, resident, 0, hash))
+      {
+         // an occurrence of some other profile;  only one profile's entries are resident
+         return null;
+      }
+
+      Entry entry = entries.get(Integer.valueOf(aotId.substring(hash + 1)));
+
+      if (entry == null || entry.flags != flags || !entry.infixDigest.equals(digest(infix)))
+      {
+         return null;
+      }
+
+      return entry;
+   }
+
+   /**
+    * Digest an expression text.
+    *
+    * @param   text
+    *          Text to digest.
+    *
+    * @return  Truncated hex encoded digest.
+    */
+   static String digest(String text)
+   {
+      try
+      {
+         byte[] raw = MessageDigest.getInstance("SHA-256")
+                                   .digest(text.getBytes(StandardCharsets.UTF_8));
+         StringBuilder buf = new StringBuilder(32);
+
+         for (byte b : raw)
+         {
+            buf.append(Character.forDigit((b >> 4) & 0xf, 16));
+            buf.append(Character.forDigit(b & 0xf, 16));
+         }
+
+         return buf.substring(0, 24);
+      }
+      catch (NoSuchAlgorithmException exc)
+      {
+         throw new IllegalStateException(exc);
+      }
+   }
+
+   /**
+    * Read the index from the classpath.
+    *
+    * @return  The entries, or an empty map if no index is present or it cannot be parsed.
+    */
+   private static Map<Integer, Entry> read(String profile)
+   {
+      byte[] raw = bytesOf(RESOURCE_DIR + indexFileName(profile));
+
+      if (raw == null)
+      {
+         // FWD built without generated expressions for this profile;  it compiles as it always did
+         return Collections.emptyMap();
+      }
+
+      Map<Integer, Entry> loaded = new HashMap<>(16384);
+
+      try (BufferedReader reader = new BufferedReader(
+              new InputStreamReader(new ByteArrayInputStream(raw), StandardCharsets.UTF_8)))
+      {
+         String header = reader.readLine();
+
+         if (header == null || !header.startsWith(MAGIC))
+         {
+            System.err.println("AotIndex: unrecognized index format, ignoring");
+
+            return Collections.emptyMap();
+         }
+
+         String line;
+
+         while ((line = reader.readLine()) != null)
+         {
+            if (line.isEmpty() || line.charAt(0) == '#')
+            {
+               continue;
+            }
+
+            String[] f = line.split("\\|", 7);
+
+            if (f.length == 7)
+            {
+               loaded.put(Integer.valueOf(f[0]),
+                          new Entry(PACKAGE + f[1], Integer.parseInt(f[2]), f[3], f[4],
+                                    split(f[5]), split(f[6])));
+            }
+         }
+      }
+      catch (Exception exc)
+      {
+         System.err.println("AotIndex: could not read index, ignoring: " + exc);
+
+         return Collections.emptyMap();
+      }
+
+      return loaded;
+   }
+
+   /**
+    * Split a comma separated symbol list.
+    *
+    * @param   text
+    *          Encoded list, possibly empty.
+    *
+    * @return  The symbol names, in slot order.
+    */
+   private static List<String> split(String text)
+   {
+      return text.isEmpty() ? Collections.emptyList() : Arrays.asList(text.split(","));
+   }
+
+   /**
+    * Locate fwd-trpl.jar, but only if it was generated from the same inputs as this p2j.jar.
+    * <p>
+    * The two carry a shared stamp over the rule sets and the expression compiler.  Disagreement
+    * means the generated classes describe rules or code generation which no longer exist, so they
+    * are ignored outright and every expression is compiled as it always was.  That is the whole
+    * staleness story:  the artifact is optional and regenerable, and being wrong is never a
+    * possible outcome of it being old.
+    *
+    * @return  An open {@link java.util.jar.JarFile}, a {@link File} directory, or
+    *          <code>null</code> if the generated classes should not be used.
+    */
+   private static Object openSource()
+   {
+      try
+      {
+         ClassLoader loader = AotIndex.class.getClassLoader();
+         String expected = text(loader.getResourceAsStream(EXPECTED_RESOURCE));
+
+         if (expected == null)
+         {
+            // this FWD build ships no generated classes
+            return null;
+         }
+
+         java.net.URL url = loader.getResource(STAMP_RESOURCE);
+
+         if (url == null)
+         {
+            // fwd-trpl.jar is not on the classpath;  perfectly fine, just slower
+            return null;
+         }
+
+         String actual = text(url.openStream());
+
+         if (!expected.equals(actual))
+         {
+            System.err.println("AotIndex: fwd-trpl.jar was generated from different sources than " +
+                                  "this p2j.jar (expected " + expected + ", found " + actual +
+                                  "); ignoring it and compiling expressions normally");
+
+            return null;
+         }
+
+         if ("jar".equals(url.getProtocol()))
+         {
+            java.net.JarURLConnection conn = (java.net.JarURLConnection) url.openConnection();
+
+            conn.setUseCaches(false);
+
+            return new java.util.jar.JarFile(new File(conn.getJarFileURL().toURI()));
+         }
+
+         if ("file".equals(url.getProtocol()))
+         {
+            // exploded on disk:  climb back out of the resource path to the classpath root
+            File root = new File(url.toURI());
+
+            for (int i = 0; i < STAMP_RESOURCE.split("/").length; i++)
+            {
+               root = root.getParentFile();
+            }
+
+            return root;
+         }
+
+         return null;
+      }
+      catch (Exception exc)
+      {
+         System.err.println("AotIndex: could not open fwd-trpl.jar, compiling normally: " + exc);
+
+         return null;
+      }
+   }
+
+   /**
+    * Read a small text resource, trimmed.
+    *
+    * @param   in
+    *          Stream to read, possibly <code>null</code>.  Closed on return.
+    *
+    * @return  The trimmed content, or <code>null</code>.
+    */
+   private static String text(InputStream in)
+   {
+      if (in == null)
+      {
+         return null;
+      }
+
+      try (InputStream stream = in)
+      {
+         return new String(stream.readAllBytes(), StandardCharsets.UTF_8).trim();
+      }
+      catch (IOException exc)
+      {
+         return null;
+      }
+   }
+
+   /**
+    * Interpret bytes as trimmed text.
+    *
+    * @param   raw
+    *          Bytes, possibly <code>null</code>.
+    *
+    * @return  The trimmed content, or <code>null</code>.
+    */
+   private static String text(byte[] raw)
+   {
+      return (raw == null) ? null : new String(raw, StandardCharsets.UTF_8).trim();
+   }
+
+   /**
+    * Read one entry from the artifact.
+    *
+    * @param   path
+    *          Entry path, relative to the artifact root.
+    *
+    * @return  The bytes, or <code>null</code> if absent.
+    */
+   private static byte[] bytesOf(String path)
+   {
+      try
+      {
+         if (SOURCE instanceof java.util.jar.JarFile)
+         {
+            java.util.jar.JarFile jar = (java.util.jar.JarFile) SOURCE;
+            java.util.zip.ZipEntry entry = jar.getEntry(path);
+
+            if (entry == null)
+            {
+               return null;
+            }
+
+            try (InputStream in = jar.getInputStream(entry))
+            {
+               return in.readAllBytes();
+            }
+         }
+
+         if (SOURCE instanceof File)
+         {
+            File file = new File((File) SOURCE, path);
+
+            return file.isFile() ? java.nio.file.Files.readAllBytes(file.toPath()) : null;
+         }
+
+         // no usable artifact:  nothing to read, and the caller compiles instead
+         return null;
+      }
+      catch (IOException exc)
+      {
+         return null;
+      }
+   }
+
+   /**
+    * Report what the generated classes did for this run.  A run which loaded an index but matched
+    * nothing is called out:  that is what a conversion looks like when its debug level differs
+    * from the one the classes were generated for, and it would otherwise be invisible.
+    */
+   private static void report()
+   {
+      if (DISABLED)
+      {
+         return;
+      }
+
+      System.err.println("AotIndex: served " + hits + ", compiled " + misses +
+                         ((unnamedMisses == 0) ? ""
+                                               : (" (" + unnamedMisses +
+                                                  " of them expressions built at run time, which " +
+                                                  "cannot be generated ahead of time)")));
+
+      // Attribute the shortfall.  A profile which misses wholesale was not generated -- excluded
+      // from the build, or it failed to load in the generator -- and that is worth knowing,
+      // because it is fixable in a way that individual misses are not.
+      byProfile.entrySet()
+               .stream()
+               .filter(e -> e.getValue()[1] > 0)
+               .sorted((a, b) -> b.getValue()[1] - a.getValue()[1])
+               .limit(10)
+               .forEach(e -> System.err.println("AotIndex:    " + e.getKey() + " served " +
+                                                e.getValue()[0] + ", compiled " +
+                                                e.getValue()[1]));
+
+      if (hits == 0 && misses > 0)
+      {
+         System.err.println("AotIndex: WARNING - an index was found but no occurrence matched it. " +
+                               "The usual cause is a conversion debug level (the Dn option) different " +
+                               "from the one the classes were generated for; expressions are being " +
+                               "compiled as if the generated classes were absent.");
+      }
+   }
+
+   /**
+    * One indexed occurrence:  which class serves it, and the state needed to bind that class to a
+    * caller's scope without parsing the expression again.
+    */
+   static final class Entry
+   {
+      /** Fully qualified name of the class which serves this occurrence. */
+      final String className;
+
+      /** Runtime flags the class was generated under. */
+      final int flags;
+
+      /** Digest of the expression text the class was generated from. */
+      final String infixDigest;
+
+      /** Name of the blueprint's return type. */
+      final String returnType;
+
+      /** Library symbol names, in slot order. */
+      final List<String> libraries;
+
+      /** Variable symbol names, in slot order. */
+      final List<String> variables;
+
+      /**
+       * Constructor.
+       *
+       * @param   className
+       *          Fully qualified name of the class which serves this occurrence.
+       * @param   flags
+       *          Runtime flags the class was generated under.
+       * @param   infixDigest
+       *          Digest of the expression text the class was generated from.
+       * @param   returnType
+       *          Name of the blueprint's return type.
+       * @param   libraries
+       *          Library symbol names, in slot order.
+       * @param   variables
+       *          Variable symbol names, in slot order.
+       */
+      Entry(String className, int flags, String infixDigest, String returnType,
+            List<String> libraries, List<String> variables)
+      {
+         this.className   = className;
+         this.flags       = flags;
+         this.infixDigest = infixDigest;
+         this.returnType  = returnType;
+         this.libraries   = libraries;
+         this.variables   = variables;
+      }
+   }
+
+   /**
+    * Loader for the generated expression classes.
+    *
+    * <h3>Why not the application loader</h3>
+    *
+    * The conversion classpath is several hundred jars.  Resolving a generated class through the
+    * application loader searches all of them, and doing that once per generated class costs
+    * seconds.  This loader knows the single artifact they live in, so a load is one zip entry read.
+    *
+    * <h3>Why loadClass is overridden and not just findClass</h3>
+    *
+    * The inherited <code>loadClass</code> is parent first:  it would consult the application loader
+    * -- and therefore search that whole classpath -- before ever calling {@link #findClass}, which
+    * is exactly the cost this loader exists to avoid.  Generated classes are never on the parent's
+    * classpath, so for them the order is inverted.  Everything else, including the superclass of
+    * every generated class and every type its code refers to, delegates normally.
+    */
+   private static final class AotClassLoader extends ClassLoader
+   {
+      static
+      {
+         // per class name locking rather than one lock for the loader:  conversion threads load
+         // these concurrently
+         registerAsParallelCapable();
+      }
+
+      /**
+       * Constructor.
+       *
+       * @param   parent
+       *          Loader to delegate everything but the generated classes to.
+       */
+      AotClassLoader(ClassLoader parent)
+      {
+         super(parent);
+      }
+
+      /**
+       * {@inheritDoc}
+       */
+      @Override
+      protected Class<?> loadClass(String name, boolean resolve)
+      throws ClassNotFoundException
+      {
+         if (!name.startsWith(PACKAGE))
+         {
+            return super.loadClass(name, resolve);
+         }
+
+         synchronized (getClassLoadingLock(name))
+         {
+            Class<?> found = findLoadedClass(name);
+
+            if (found == null)
+            {
+               found = findClass(name);
+            }
+
+            if (resolve)
+            {
+               resolveClass(found);
+            }
+
+            return found;
+         }
+      }
+
+      /**
+       * {@inheritDoc}
+       */
+      @Override
+      protected Class<?> findClass(String name)
+      throws ClassNotFoundException
+      {
+         byte[] data = bytesOf(name.replace('.', '/') + ".class");
+
+         if (data == null)
+         {
+            throw new ClassNotFoundException(name);
+         }
+
+         return defineClass(name, data, 0, data.length);
+      }
+   }
+}
diff -Nru old/src/com/goldencode/expr/Compiler.java new/src/com/goldencode/expr/Compiler.java
--- old/src/com/goldencode/expr/Compiler.java	2026-09-25 09:27:23.808572431 +0300
+++ new/src/com/goldencode/expr/Compiler.java	2026-09-25 09:27:23.846480642 +0300
@@ -57,6 +57,8 @@
 **                           Cloned global variables during instantiation.
 **                           Reserved class name atomically upfront in process() and synchronize
 **                           load() on the shared loader instance to fix concurrent compilation.
+** 019 TG  20260909          compile() takes the compile-time scope, which is now part of the
+**                           compiler cache key.
 */
 
 /*
@@ -117,6 +119,7 @@
 import java.io.*;
 import java.lang.reflect.*;
 import java.util.*;
+import java.util.concurrent.*;
 import java.util.concurrent.atomic.*;
 import java.util.logging.*;
 
@@ -253,6 +256,13 @@
    
    /** Instance of compiled expression custom subclass */
    private CompiledExpression compiled = null;
+
+   /**
+    * Compilers restored from the classes generated when FWD was built, keyed by the whole of the
+    * index entry they were built from -- class, return type and both slot maps.  See
+    * {@link #restore} for why the class name alone is not a sufficient key.
+    */
+   private static final Map<String, Compiler> RESTORED = new ConcurrentHashMap<>();
    
    /** Constant for rounding floating point calculations to fixed precision */
    private double roundingConstant = 0.0;
@@ -292,6 +302,10 @@
     *          to be compiled, including the expression text in infix
     *          notation, and runtime flags which impact the behavior of
     *          the compiled expression.
+    * @param   scope
+    *          Scope in force for this compilation.  It must be the scope the caller resolved
+    *          before this call, not one re-derived here, so that the lookup and the publication
+    *          agree on the key.
     *
     * @return  Instance of a custom subclass of
     *          <code>CompiledExpression</code>.
@@ -299,17 +313,19 @@
     * @throws  CompilerException
     *          if any error occurs during compilation of the expression.
     */
-   static CompiledExpression compile(Expression expression)
+   static CompiledExpression compile(Expression expression, Scope scope)
    throws CompilerException
    {
-      // Check if there is already a compiler for this expression.
-      Compiler compiler = expression.getCompiler();
+      // Check if there is already a compiler for this expression in this scope.  The lookup is
+      // scope aware because a generated class is specific to the scope which compiled it:  the
+      // types of the variables it reads are frozen into its bytecode.
+      Compiler compiler = expression.getCompiler(scope);
       if (compiler == null)
       {
          compiler = new Compiler(expression);
       }
       CompiledExpression compiled = compiler.process(expression.getResolver());
-      expression.putCompiler(compiler);
+      expression.putCompiler(compiler, scope);
       return compiled;
    }
    
@@ -353,17 +369,6 @@
    CompiledExpression process(SymbolResolver resolver)
    throws CompilerException
    {
-      // Create a class file object into which to install execute method.
-      int myIndex = classCount.getAndIncrement();  // reserve atomically upfront
-      classFile = new ClassFile(
-              PKG_PREFIX + CLASS_NAME_PREFIX + myIndex,
-              CLASS_COMPILEDEXPRESSION
-      );
-      
-      // Get constant pool, which is needed during assembly and
-      // compilation.
-      constantPool = classFile.getConstantPool();
-      
       try
       {
          // Parse infix expression into AST.
@@ -378,12 +383,41 @@
             ExpressionParser.visit(tree, 0);
          }
          
+         // Everything the generated code depends on is resolved at this point and nothing has been
+         // assembled yet, which is both where the compilation can be described and therefore where
+         // a name derived from that description can be chosen.  Only the generator needs such a
+         // name;  an ordinary run keeps the sequential naming, and the two live in different
+         // packages so they can never collide.
+         String internal;
+         
+         if (AotGenerator.isGenerating())
+         {
+            internal = AotGenerator.internalName(signature());
+         }
+         else
+         {
+            internal = PKG_PREFIX + CLASS_NAME_PREFIX + classCount.getAndIncrement();
+         }
+         
+         // Create a class file object into which to install execute method.  Note that ClassFile
+         // takes the internal, slash separated form, which is what ends up in the constant pool.
+         classFile = new ClassFile(internal, CLASS_COMPILEDEXPRESSION);
+         
+         // Get constant pool, which is needed during assembly and
+         // compilation.
+         constantPool = classFile.getConstantPool();
+
          // Assemble core bytecode for execute method.
          CodeUnit unit = assemble(tree);
          
          // Prepare class file and wrap execute bytecode with return logic.
          prepare(unit);
          
+         if (AotGenerator.isGenerating())
+         {
+            AotGenerator.emit(expression, classFile, returnType, libraries, variables);
+         }
+         
          // Load the class using the custom classloader.
          load();
          
@@ -429,6 +463,325 @@
    }
    
    /**
+    * Build a compiler around a class which was generated when FWD was built, without parsing,
+    * resolving or assembling anything.
+    * <p>
+    * The result is equivalent, as far as {@link #instantiate} is concerned, to the compiler which
+    * originally produced the class:  it carries the same symbol to slot maps and the same return
+    * type, which is all that binding a blueprint to a caller's scope requires.
+    *
+    * @param   expression
+    *          Expression the class belongs to.
+    * @param   entry
+    *          Index entry describing the class.
+    *
+    * @return  The compiler, or <code>null</code> if the class could not be used, in which case the
+    *          caller compiles as usual.
+    */
+   static Compiler restore(Expression expression, AotIndex.Entry entry)
+   {
+      // Occurrences outnumber restored compilers several to one, and this runs per occurrence, so
+      // without a cache each one pays a reflective instantiation and a rebuild of the slot maps
+      // for state already assembled.
+      //
+      // The key is NOT the class name alone.  Generated classes are folded together when their
+      // bytecode is identical bar their own name, and only slot *indices* are baked into that
+      // bytecode -- the symbol names mapped onto those indices live here, not in the class.  Two
+      // occurrences can therefore share a class while resolving different variables through the
+      // same slots, so everything this compiler takes from the entry has to be part of the key.
+      // (Loading the class itself is still shared across all of them:  the class loader caches by
+      // name, so the define and verify happen once regardless.)
+      String cacheKey = entry.className + '\u0000' + entry.returnType + '\u0000' +
+                        entry.libraries + '\u0000' + entry.variables;
+
+      Compiler cached = RESTORED.get(cacheKey);
+
+      if (cached != null)
+      {
+         return cached;
+      }
+
+      Compiler compiler = new Compiler(expression);
+
+      try
+      {
+         // resolved directly from the artifact holding it, rather than by searching the whole
+         // conversion classpath once per generated class
+         compiler.customClass = AotIndex.loadClass(entry.className);
+
+         compiler.returnType = "null".equals(entry.returnType) ? null : forName(entry.returnType);
+
+         slots(entry.libraries, compiler.libraries);
+         slots(entry.variables, compiler.variables);
+
+         compiler.compiled =
+            (CompiledExpression) compiler.customClass.getDeclaredConstructor().newInstance();
+         compiler.compiled.setReturnType(compiler.returnType);
+         compiler.compiled.setLibraries(new Object[0]);
+         compiler.compiled.setVariables(new Variable[0]);
+         compiler.compiled.setThreadId(Thread.currentThread().getId());
+
+         Compiler previous = RESTORED.putIfAbsent(cacheKey, compiler);
+
+         return (previous == null) ? compiler : previous;
+      }
+      catch (Throwable thr)
+      {
+         // any trouble at all and the caller falls back to compiling, which is always correct
+         System.err.println("AotIndex: could not use " + entry.className + ": " + thr);
+
+         return null;
+      }
+   }
+
+   /**
+    * Get the scope independent blueprint instance of the generated class.  Callers clone it and
+    * bind the clone to their own scope through {@link #instantiate}.
+    *
+    * @return  The blueprint, or <code>null</code> if this compiler has not produced one.
+    */
+   CompiledExpression getBlueprint()
+   {
+      return compiled;
+   }
+
+   /**
+    * Resolve a type name, allowing for the primitives which are not loadable classes.
+    *
+    * @param   name
+    *          Fully qualified type name.
+    *
+    * @return  The class.
+    *
+    * @throws  ClassNotFoundException
+    *          if the name cannot be resolved.
+    */
+   private static Class<?> forName(String name)
+   throws ClassNotFoundException
+   {
+      switch (name)
+      {
+         case "boolean": return Boolean.TYPE;
+         case "byte":    return Byte.TYPE;
+         case "char":    return Character.TYPE;
+         case "short":   return Short.TYPE;
+         case "int":     return Integer.TYPE;
+         case "long":    return Long.TYPE;
+         case "float":   return Float.TYPE;
+         case "double":  return Double.TYPE;
+         case "void":    return Void.TYPE;
+         default:        return Class.forName(name);
+      }
+   }
+
+   /**
+    * Rebuild a symbol to slot map from the symbol names in slot order.
+    *
+    * @param   names
+    *          Symbol names, in slot order.
+    * @param   target
+    *          Map to populate.
+    */
+   private static void slots(List<String> names, Map<String, Integer> target)
+   {
+      for (int i = 0; i < names.size(); i++)
+      {
+         target.put(names.get(i), Integer.valueOf(i));
+      }
+   }
+
+   /**
+    * Compose a canonical description of everything the generated bytecode depends on, so that two
+    * compilations which would produce interchangeable classes describe themselves identically.
+    * <p>
+    * Must be called after {@link #parse} and {@link #preprocess}:  the point at which every symbol
+    * has been resolved against the scope in force, but nothing has been assembled.
+    * <p>
+    * Deliberately <b>over-inclusive</b> -- each node's type together with a canonical rendering of
+    * whatever payload the parser attached, falling back to the payload's class and
+    * <code>toString</code> for anything unrecognized.  Over-inclusion only costs a redundant class;
+    * omitting something the assembler uses would let two classes with different casts share a
+    * description, which is exactly the defect this description exists to prevent.
+    *
+    * @return  Canonical description of this compilation.
+    */
+   String signature()
+   {
+      StringBuilder buf = new StringBuilder(256);
+
+      buf.append("infix=").append(expression.getInfix())
+         .append("\nflags=").append(expression.getRuntimeFlags())
+         .append("\nprecision=").append(expression.getPrecision())
+         .append("\nresult=").append(name(resultType))
+         .append("\nreturn=").append(name(returnType))
+         .append("\nlibs=").append(slotNames(libraries))
+         .append("\nvars=").append(slotNames(variables));
+
+      describe(tree, buf);
+
+      return buf.toString();
+   }
+
+   /**
+    * Append a canonical description of a node and, recursively, of its children.
+    *
+    * @param   ast
+    *          Node to describe, or <code>null</code>.
+    * @param   buf
+    *          Target buffer.
+    */
+   private void describe(ExtraAst ast, StringBuilder buf)
+   {
+      if (ast == null)
+      {
+         return;
+      }
+
+      buf.append("\n(").append(ast.getType());
+
+      Class<?> operand = ast.getOperandType();
+
+      if (operand != null)
+      {
+         buf.append(" op=").append(name(operand));
+      }
+
+      Object extra = ast.getExtra();
+
+      if (extra instanceof LogicalData)
+      {
+         // the branch bookkeeping is derived from the tree shape, which is described anyway;  only
+         // the payload it wraps affects the emitted code
+         extra = ((LogicalData) extra).getExtra();
+         buf.append(" logical");
+      }
+
+      if (extra instanceof Function)
+      {
+         Function func = (Function) extra;
+         String symbol = func.getSymbol();
+
+         // every component is spelled out rather than taken from Function.toString(), which
+         // abbreviates:  it omits the method's declaring class, and two methods differing only
+         // there assemble to different invocations
+         Method method = func.getMethod();
+
+         buf.append(" kind=").append(func.getType())
+            .append(" sym=").append(symbol)
+            .append(" target=").append(name(func.getTarget()))
+            .append(" ret=").append(name(func.getReturnType()))
+            .append(" minArgs=").append(func.getMinArgs())
+            .append(" varArg=").append(func.isVarArg())
+            .append(" params=").append(names(func.getParameterTypes()));
+
+         if (method == null)
+         {
+            buf.append(" m=null");
+         }
+         else
+         {
+            buf.append(" m=").append(name(method.getDeclaringClass()))
+               .append('#').append(method.getName())
+               .append(names(method.getParameterTypes()))
+               .append(':').append(name(method.getReturnType()))
+               .append('/').append(method.getModifiers());
+         }
+
+         if (symbol != null)
+         {
+            // the slot index is emitted into the bytecode as a literal
+            Integer slot = libraries.get(symbol);
+
+            if (slot == null)
+            {
+               slot = variables.get(symbol);
+            }
+
+            buf.append(" slot=").append(slot);
+         }
+      }
+      else if (extra != null)
+      {
+         // unrecognized payload:  describe it rather than ignore it
+         buf.append(" extra=").append(extra.getClass().getName())
+            .append('[').append(extra).append(']');
+      }
+
+      buf.append(" text=").append(ast.getText());
+
+      describe((ExtraAst) ast.getFirstChild(), buf);
+
+      buf.append(')');
+
+      describe((ExtraAst) ast.getNextSibling(), buf);
+   }
+
+   /**
+    * Render a symbol to slot map in slot order.
+    *
+    * @param   slots
+    *          Map of symbol name to slot index.
+    *
+    * @return  Canonical rendering.
+    */
+   private static String slotNames(Map<String, Integer> slots)
+   {
+      String[] byIndex = new String[slots.size()];
+
+      for (Map.Entry<String, Integer> next : slots.entrySet())
+      {
+         byIndex[next.getValue()] = next.getKey();
+      }
+
+      return Arrays.toString(byIndex);
+   }
+
+   /**
+    * Render a class reference, tolerating <code>null</code>.
+    *
+    * @param   type
+    *          Class to render, or <code>null</code>.
+    *
+    * @return  The class name, or <code>"null"</code>.
+    */
+   private static String name(Class<?> type)
+   {
+      return (type == null) ? "null" : type.getName();
+   }
+
+   /**
+    * Render an array of class references, tolerating <code>null</code>.
+    *
+    * @param   types
+    *          Classes to render, or <code>null</code>.
+    *
+    * @return  Canonical rendering.
+    */
+   private static String names(Class<?>[] types)
+   {
+      if (types == null)
+      {
+         return "null";
+      }
+
+      StringBuilder buf = new StringBuilder(32);
+
+      buf.append('(');
+
+      for (int i = 0; i < types.length; i++)
+      {
+         if (i > 0)
+         {
+            buf.append(',');
+         }
+
+         buf.append(name(types[i]));
+      }
+
+      return buf.append(')').toString();
+   }
+
+   /**
     * This method tracks changes in the current and maximum stack depths. It
     * is called by the <code>Instruction</code> class for each instruction
     * in a method's code attribute.  The maximum stack depth is ultimately
diff -Nru old/src/com/goldencode/expr/Expression.java new/src/com/goldencode/expr/Expression.java
--- old/src/com/goldencode/expr/Expression.java	2026-09-25 09:27:23.813359810 +0300
+++ new/src/com/goldencode/expr/Expression.java	2026-09-25 09:27:23.847012251 +0300
@@ -59,6 +59,11 @@
 ** 013 AOG 20260220          Added global cache for compilers to make threads share the same compiler
 **                           for the same expressions. Added lazy instantiation of compiled expression.
 **                           Added clone method implementation used for thread-specific expressions.
+** 014 TG  20260909          Added the compile-time scope to CacheKey, so that the compiler cache
+**                           can no longer hand a class typed for one scope to another.  Read the
+**                           compiler and instance caches before taking the compile lock, so that a
+**                           cache hit does not serialize across conversion threads.  Build the
+**                           cache key once per lookup.
 */
 /*
 ** This program is free software: you can redistribute it and/or modify
@@ -153,6 +158,21 @@
  * expression residing in the cache, then the cached, compiled expression is
  * used. Otherwise, the expression is considered new and it is compiled and
  * cached.
+ *
+ * <h3>Caching model:  a compiled expression is bound to its scope</h3>
+ *
+ * A generated <code>CE&lt;n&gt;</code> class is <b>specific to the scope which compiled it</b>
+ * and must never be executed on behalf of a different scope.  Symbol resolution types every
+ * user variable accessor from <code>var.getType()</code> as found in the scope in force at
+ * compile time, and since <code>CompiledExpression.getVar(int)</code> returns
+ * <code>Object</code>, the assembler freezes that type into a <code>checkcast</code>.  The
+ * <em>name to slot index</em> maps a {@link Compiler} holds are rebound per caller by
+ * {@link Compiler#instantiate};  the cast types are not.
+ * <p>
+ * {@link CacheKey} therefore includes the scope alongside the infix text and the runtime flags.
+ * Without it, two rulesets declaring an identically named variable with different types collide
+ * on one key, and the expression fails at runtime with a <code>ClassCastException</code> raised
+ * inside the generated class.
  * <p>
  * Several runtime flag constants can be set to alter the behavior of an
  * expression. If multiple flags are to be set, they are bitwise OR'd
@@ -214,11 +234,28 @@
    /** Line separator */
    private static final String LINE_SEP = System.getProperty("line.separator");
 
-   /** Shared cache that maps an expression to its compiler */
+   /**
+    * Shared cache that maps an expression, in a given scope, to its compiler.  The key includes
+    * the scope;  see <i>Caching model</i> in the class javadoc.
+    */
    private static final Map<CacheKey, Compiler> compilers = new ConcurrentHashMap<>();
 
    /** Shared cache locks based on expression test */
    private static final Map<CacheKey, Lock> locks = new ConcurrentHashMap<>();
+
+   /**
+    * Profile whose rule sets are currently being loaded, or <code>null</code> outside a load.
+    * <p>
+    * Loading is single threaded and reads a fixed set of files in document order -- the profiles
+    * name every include and rule set explicitly, with no wildcards -- so the expressions of a
+    * profile are created in a stable sequence.  That makes <em>(profile, position in the
+    * sequence)</em> a durable name for an occurrence, which is what lets the class generated for it
+    * when FWD was built be found again.
+    */
+   private static String aotProfile = null;
+
+   /** Source of positions within the profile currently being loaded. */
+   private static int aotOrdinal = 0;
    
    /** Symbol resolver used to compile this expression */
    private final WeakReference<SymbolResolver> resolver;
@@ -237,6 +274,13 @@
    
    /** Flag used to determine whether a cache check is necessary */
    private boolean dirty = false;
+
+   /**
+    * Durable name of this expression occurrence, or <code>null</code> if it did not come from a
+    * rule set load and so has no generated counterpart.  Carried through {@link #clone}, so every
+    * thread's copy of one occurrence shares a single name.
+    */
+   private String aotId = null;
    
    /**
     * Construct a new instance of an <code>Expression</code> using the
@@ -559,7 +603,66 @@
    }
 
    /**
-    * Clears global compiler cache.
+    * Note that the rule sets of a profile are about to be loaded, so that each expression created
+    * during that load can be given a durable name.  Loading is single threaded, so this needs no
+    * synchronization.
+    *
+    * @param   profile
+    *          Name of the profile being loaded.
+    */
+   public static void beginProfileLoad(String profile)
+   {
+      aotProfile = profile;
+      aotOrdinal = 0;
+
+      AotIndex.selectProfile(profile);
+   }
+
+   /**
+    * Note that a profile load has finished.  Expressions created afterwards -- from a dynamically
+    * assembled expression text, say -- are not part of the profile's stable sequence and are left
+    * unnamed, so that they compile normally.  They cannot disturb the names already handed out.
+    */
+   public static void endProfileLoad()
+   {
+      aotProfile = null;
+   }
+
+   /**
+    * Give this expression the next durable name in the profile being loaded.  Does nothing outside
+    * a profile load.
+    */
+   public void assignAotId()
+   {
+      if (aotProfile != null)
+      {
+         aotId = aotProfile + '#' + (aotOrdinal++);
+      }
+   }
+
+   /**
+    * Get this expression occurrence's durable name.
+    *
+    * @return  The name, or <code>null</code> if it did not come from a rule set load.
+    */
+   public String getAotId()
+   {
+      return aotId;
+   }
+
+   /**
+    * Force this expression to be compiled, without executing it.  Used when generating the classes
+    * of the shipped rule sets;  the caller is responsible for having put the expression's own scope
+    * in force first.
+    */
+   public void warm()
+   {
+      getCompiledInstance();
+   }
+
+   /**
+    * Clears global compiler cache.  Invoked when a ruleset goes out of service;  the keys hold
+    * the scopes of that ruleset, so the entries are dead at that point anyway.
     */
    public void cleanup()
    {
@@ -715,37 +818,94 @@
          return compiled;
       }
       
-      // Check cache; compile if necessary.
+      // Check cache; compile if necessary.  The key is built once and reused for every lookup
+      // below, as constructing it hashes the expression text.
       SymbolResolver sr = resolver.get();
-      CacheKey key = new CacheKey(this);
       Scope scope = sr.getCurrentScope();
-      Lock lock = locks.computeIfAbsent(key, k -> new ReentrantLock());
-      CompiledExpression instance;
+      CacheKey key = new CacheKey(this, scope);
 
-      lock.lock();
-      try
+      // A cached instance is unusable without its compiler, which performs the instantiation
+      // below.  Both are read before the lock is taken, so that a cache hit does not have to
+      // queue behind another thread's compile.
+      CompiledExpression instance = sr.getCachedInstance(scope, key);
+      Compiler compiler = compilers.get(key);
+
+      if ((instance == null || compiler == null) && AotIndex.isAvailable())
       {
-         instance = sr.getCachedInstance(scope, key);
+         // This occurrence's class may have been generated when FWD was built.  Using it needs no
+         // parsing, resolution or assembly, and so nothing that the compile lock orders, which is
+         // why it is attempted before the lock is taken.
+         AotIndex.Entry entry = AotIndex.lookup(aotId, infix, flags);
 
-         // A cached instance is unusable without its compiler, which performs the instantiation below.
-         if (instance == null || getCompiler() == null)
+         if (entry == null)
          {
-            // Nothing usable in the cache; we must compile.
-            instance = Compiler.compile(this);
-            sr.putCachedInstance(scope, key, instance);
+            if (aotId == null)
+            {
+               AotIndex.unnamedMiss();
+            }
+            else
+            {
+               AotIndex.miss();
+            }
+         }
+         else
+         {
+            Compiler restored = Compiler.restore(this, entry);
+
+            if (restored == null)
+            {
+               AotIndex.miss();
+            }
+            else
+            {
+               AotIndex.hit();
+
+               compiler = compilers.putIfAbsent(key, restored);
+
+               if (compiler == null)
+               {
+                  compiler = restored;
+               }
+
+               instance = compiler.getBlueprint();
+
+               sr.putCachedInstance(scope, key, instance);
+            }
          }
       }
-      catch (CompilerException exc)
-      {
-         // Wrap this in an unchecked exception, so the caller does not
-         // take the mandatory performance hit of a try-catch around
-         // every call to execute the expression.
-         throw new ExpressionException(
-                 "Expression error [" + infix + "]", exc);
-      }
-      finally
+      
+      if (instance == null || compiler == null)
       {
-         lock.unlock();
+         Lock lock = locks.computeIfAbsent(key, k -> new ReentrantLock());
+
+         lock.lock();
+         try
+         {
+            // re-check under the lock; another thread may have compiled this expression while
+            // we were waiting for it
+            instance = sr.getCachedInstance(scope, key);
+
+            if (instance == null || compilers.get(key) == null)
+            {
+               // Nothing usable in the cache; we must compile.
+               instance = Compiler.compile(this, scope);
+               sr.putCachedInstance(scope, key, instance);
+            }
+
+            compiler = compilers.get(key);
+         }
+         catch (CompilerException exc)
+         {
+            // Wrap this in an unchecked exception, so the caller does not
+            // take the mandatory performance hit of a try-catch around
+            // every call to execute the expression.
+            throw new ExpressionException(
+                    "Expression error [" + infix + "]", exc);
+         }
+         finally
+         {
+            lock.unlock();
+         }
       }
 
       compiled = instance.clone(sr);
@@ -753,33 +913,40 @@
       dirty = false;
 
       // Trigger the lazy instantiation
-      this.getCompiler().instantiate(compiled, resolver.get());
+      compiler.instantiate(compiled, sr);
 
       return compiled;
    }
 
    /**
-    * Registers a compiled expression in the global cache.
+    * Registers a compiled expression in the global cache, under the given scope.
     *
     * @param  compiler
     *         The {@link Compiler} instance to be cached for future reuse.
+    * @param  scope
+    *         Scope whose symbols the compiler resolved.  This must be the same scope the
+    *         caller used to look the compiler up, so that publication and lookup agree on
+    *         the key.
     */
-   public void putCompiler(Compiler compiler)
+   public void putCompiler(Compiler compiler, Scope scope)
    {
-      CacheKey key = new CacheKey(this);
-      compilers.putIfAbsent(key, compiler);
+      compilers.putIfAbsent(new CacheKey(this, scope), compiler);
    }
 
    /**
-    * Retrieves a previously cached compiler for the current expression.
+    * Retrieves a previously cached compiler for this expression in the given scope.  A compiler
+    * found this way generated its class for that same scope, so its blueprint may be reused as
+    * is.
+    *
+    * @param    scope
+    *           Scope to look the compiler up for.
     *
     * @return   The cached {@link Compiler} instance, or {@code null}
     *           if no match exists in the cache.
     */
-   public Compiler getCompiler()
+   public Compiler getCompiler(Scope scope)
    {
-      CacheKey key = new CacheKey(this);
-      return compilers.get(key);
+      return compilers.get(new CacheKey(this, scope));
    }
 
    /**
@@ -791,6 +958,17 @@
     * <li>the set of {@link #setRuntimeFlags runtime flags} set for the
     *     expression.
     * </ul>
+    * <p>
+    * <li>the {@link Scope} in force when the expression is compiled.
+    * </ul>
+    * <p>
+    * The scope is part of the key because a generated expression class is specific to the scope
+    * which compiled it:  symbol resolution types every user variable accessor from
+    * <code>var.getType()</code> as found in that scope, and the assembler freezes that type into
+    * a <code>checkcast</code>.  Two rulesets declaring an identically named variable with
+    * different types produce the same text and flags but incompatible classes, so a key without
+    * a scope would conflate them and the expression would fail at runtime with a
+    * <code>ClassCastException</code> inside the generated class.
     */
    private static class CacheKey
    {
@@ -800,17 +978,23 @@
       /** Runtime flags set for expression */
       private final int flags;
       
+      /** Scope in force when the expression was compiled;  may be <code>null</code>. */
+      private final Scope scope;
+      
       /**
        * Convenience constructor which instantiates a new key and sets the
        * internal key state, based upon the expression state.
        *
        * @param   expr
        *          Expression object on which the cache key state is based.
+       * @param   scope
+       *          Scope in force for this lookup, or <code>null</code> if none.
        */
-      CacheKey(Expression expr)
+      CacheKey(Expression expr, Scope scope)
       {
          this.infix = expr.infix;
          this.flags = expr.flags;
+         this.scope = scope;
       }
       
       /**
@@ -825,6 +1009,7 @@
          int result = 17;
          result = 37 * result + flags;
          result = 37 * result + infix.hashCode();
+         result = 37 * result + ((scope == null) ? 0 : scope.hashCode());
          
          return result;
       }
@@ -850,7 +1035,8 @@
          // Assume case is safe.
          CacheKey that = (CacheKey) o;
          
-         return (infix.equals(that.infix) && flags == that.flags);
+         return (infix.equals(that.infix) && flags == that.flags &&
+                 ((scope == null) ? (that.scope == null) : scope.equals(that.scope)));
       }
    }
    
diff -Nru old/src/com/goldencode/expr/SymbolResolver.java new/src/com/goldencode/expr/SymbolResolver.java
--- old/src/com/goldencode/expr/SymbolResolver.java	2026-09-25 09:27:23.817947205 +0300
+++ new/src/com/goldencode/expr/SymbolResolver.java	2026-09-25 09:27:23.847315524 +0300
@@ -83,6 +83,8 @@
 **                           Removed synchronized modifier for a few methods as the context-local is used.
 **         20260724          Replaced locking system with synchronized map to reduce bottleneck.
 **                           Moved shared maps to concurrent maps to guarantee synchronization.
+** 020 TG  20260909          Documented that a generated expression class is bound to the scope which
+**                           compiled it, as its variable accessor types are frozen into bytecode.
 */
 /*
 ** This program is free software: you can redistribute it and/or modify
@@ -339,7 +341,19 @@
       }
    }
    
-   /** Compiled expression instance cache */
+   /**
+    * Compiled expression instance cache.
+    * <p>
+    * Keyed by scope first, then by the expression's cache key, because a generated expression
+    * class is specific to the scope which compiled it.  See <i>Caching model</i> in the
+    * {@link Expression} class javadoc.
+    * <p>
+    * The scope keys are held weakly, so an entry is discarded once the ruleset which owns that
+    * scope is out of service.  Note that this tracks the identity of the specific
+    * <code>Scope</code> object which performed the <code>put</code>:  {@link Scope#equals}
+    * matches clones by <code>originId</code>, so a per-thread clone can look the entry up, but
+    * the entry still dies with the particular clone that created it.
+    */
    private static final Map<Scope, Map<Object, CompiledExpression>> instCache = Collections.synchronizedMap(new WeakHashMap<>());
 
    /** Marker object used to represent a not found function from the concurrent caches. */
@@ -731,6 +745,13 @@
       }
    }
 
+   public final String debugLibraryNames(Scope scope)
+   {
+      Map<String, Object> library = scope.getLibraries(this, false);
+
+      return (library == null) ? "<none for this resolver>" : library.keySet().toString();
+   }
+
    /**
     * Check whether a user expression has permission to invoke the specified
     * method on the specified target class. This method is called during
@@ -1098,6 +1119,10 @@
             }
             else
             {
+               // NOTE:  this return type is what makes a generated expression class specific to
+               // the scope which compiled it.  getVar(int) returns Object, so the assembler
+               // emits a checkcast to the type named here, frozen into the bytecode -- unlike
+               // the variable's slot index, which instantiate() rebinds per caller.
                result = new Function(CompiledExpression.class,
                                      var.getType(),
                                      METH_GET_VAR);
diff -Nru old/src/com/goldencode/p2j/pattern/AotWarmer.java new/src/com/goldencode/p2j/pattern/AotWarmer.java
--- old/src/com/goldencode/p2j/pattern/AotWarmer.java	1970-01-01 02:00:00.000000000 +0200
+++ new/src/com/goldencode/p2j/pattern/AotWarmer.java	2026-09-25 09:27:23.847693183 +0300
@@ -0,0 +1,255 @@
+/*
+** Module   : AotWarmer.java
+** Abstract : Build time driver which compiles the expressions of the shipped rule sets
+**
+** Copyright (c) 2026, Golden Code Development Corporation.
+**
+** -#- -I- --Date-- ----------------Description-----------------
+** 001 TG  20260909 Initial version.
+*/
+
+package com.goldencode.p2j.pattern;
+
+import java.io.*;
+import java.util.*;
+
+import com.goldencode.expr.AotGenerator;
+import com.goldencode.expr.Expression;
+
+/**
+ * Build time driver which loads each shipped profile and compiles every expression it contains, so
+ * that the resulting classes can be packaged with FWD instead of being generated again by every
+ * conversion.
+ *
+ * <h3>Why this drives the ordinary machinery</h3>
+ *
+ * The classes produced here have to be exactly the ones a conversion would have produced, and the
+ * only way to be certain of that is to produce them the same way.  This driver therefore loads a
+ * profile through {@link PatternEngine} as usual and then walks the resulting rule tree, putting
+ * each container or function in scope and asking its expressions to compile.  Nothing is executed:
+ * an expression is compiled by asking for its return type, which is the same lazy compilation a
+ * conversion triggers on first use.
+ * <p>
+ * Scope matters and is the reason for the walk.  A generated class bakes in the types of the
+ * variables it reads, resolved from the scope which compiled it, so each expression has to be
+ * compiled under its own container or function -- exactly as the walk arranges.
+ *
+ * <h3>Coverage is an optimization, not a contract</h3>
+ *
+ * Anything this driver misses -- an expression whose profile fails to load, one which cannot be
+ * compiled outside a running conversion, a variable initializer -- is simply absent from the index,
+ * and a conversion which reaches it compiles it exactly as it does today.  Failures are therefore
+ * counted and reported rather than allowed to fail the build.
+ */
+public final class AotWarmer
+{
+   /** Number of expressions which could not be compiled ahead of time. */
+   private static int failures = 0;
+
+   /** One example failure per distinct message, to keep the build log readable. */
+   private static final Map<String, String> examples = new TreeMap<>();
+
+   /** How many failures each distinct reason accounts for. */
+   private static final Map<String, Integer> counts = new TreeMap<>();
+
+   /** Number of profiles which could not be loaded. */
+   private static int skipped = 0;
+
+   /**
+    * Flag enabling the per profile and per failure detail.  Off by default:  in a normal build
+    * this is a cache being primed, and the only thing worth a line is that it happened.  Set
+    * <code>-Dp2j.aot.verbose=true</code> to see which profiles were skipped and why expressions
+    * were left to run time.
+    */
+   private static final boolean VERBOSE = Boolean.getBoolean("p2j.aot.verbose");
+
+   /**
+    * Generate the expression classes of every profile named, and write them out.
+    * <p>
+    * Invoked by the build.  The first argument is the output directory;  the rest are profile
+    * names, as a conversion would name them.
+    *
+    * @param   args
+    *          Output directory, followed by one or more profile names.
+    *
+    * @throws  Exception
+    *          if the output cannot be written.
+    */
+   public static void main(String[] args)
+   throws Exception
+   {
+      if (args.length < 2)
+      {
+         System.err.println("usage: AotWarmer <output-dir> <profile> [<profile> ...]");
+         System.exit(2);
+      }
+
+      File outputDir = new File(args[0]);
+      int loaded = 0;
+
+      // Expression runtime flags are derived from the debug level (see
+      // AstSymbolResolver.createExpression), and they change the generated bytecode, so they form
+      // part of the index key.  This MUST match the level conversions run at -- the Dn option of
+      // ConversionDriver, e.g. the d0 in "-SId0".  A mismatch is not incorrect, but every lookup
+      // misses and the whole mechanism silently does nothing.
+      int level = Integer.getInteger("p2j.aot.debug-level", DebugLevels.MSG_NONE);
+
+      RuleContainer.setDebugLevel(level);
+
+      AotGenerator.setGenerating(true);
+
+      for (int i = 1; i < args.length; i++)
+      {
+         if (generate(args[i]))
+         {
+            loaded++;
+         }
+      }
+
+      AotGenerator.setGenerating(false);
+      AotGenerator.write(outputDir, level);
+
+      System.out.println("AOT expressions: " + AotGenerator.classCount() + " classes for " +
+                         AotGenerator.occurrences() + " occurrences, " + loaded + "/" +
+                         (args.length - 1) + " profiles, debug level " + level +
+                         ((failures == 0) ? ""
+                                          : (" (" + failures + " left to run time)")));
+
+      // Loading a profile brings up the artifact and call graph stores, which run non daemon
+      // threads that nothing here shuts down;  without this the JVM lingers after generation is
+      // finished, holding the build open and skewing anything measured alongside it.
+      if (VERBOSE)
+      {
+         counts.entrySet()
+               .stream()
+               .sorted((a, b) -> b.getValue() - a.getValue())
+               .limit(12)
+               .forEach(next -> System.out.println("   " + next.getValue() + "x  " + next.getKey() +
+                                                   "  -- e.g. " + examples.get(next.getKey())));
+      }
+
+      System.out.flush();
+      System.exit(0);
+   }
+
+   /**
+    * Compile a rule list element, whichever kind it is.
+    *
+    * @param   element
+    *          Element to warm.
+    * @param   resolver
+    *          AST symbol resolver.
+    */
+   static void warm(RuleListElement element, AstSymbolResolver resolver)
+   {
+      if (element instanceof Rule)
+      {
+         ((Rule) element).warm(resolver);
+      }
+      else if (element instanceof RuleContainer)
+      {
+         ((RuleContainer) element).warm(resolver);
+      }
+   }
+
+   /**
+    * Note that an expression could not be compiled ahead of time.
+    *
+    * @param   expression
+    *          The expression.
+    * @param   thr
+    *          Why it could not be compiled.
+    */
+   static synchronized void failed(Expression expression, Throwable thr)
+   {
+      failures++;
+
+
+      Throwable root = thr;
+
+      while (root.getCause() != null)
+      {
+         root = root.getCause();
+      }
+
+      // key on the root cause, not the wrapper:  the wrapper's message embeds the expression text,
+      // so keying on it just counts expressions rather than reasons
+      String reason = root.getClass().getSimpleName() +
+                      ((root.getMessage() == null) ? "" : (": " + root.getMessage()));
+
+      // collapse the varying symbol name so that one reason does not appear hundreds of times
+      reason = reason.replaceAll("\\(setter == \\w+, name == \\([^)]*\\)\\)", "(<symbol>)");
+
+      counts.merge(reason, 1, Integer::sum);
+      examples.putIfAbsent(reason, expression.getInfix());
+   }
+
+   /**
+    * Load one profile and compile every expression in it.
+    *
+    * @param   profile
+    *          Profile name.
+    *
+    * @return  <code>true</code> if the profile was loaded.
+    */
+   private static boolean generate(String profile)
+   {
+      PatternEngine engine = new PatternEngine();
+
+      try
+      {
+         // Hold the engine in its initializing state for the whole walk.  That keeps the shared
+         // rule tree in play instead of the per thread copies a conversion would take, which is
+         // both what we want to enumerate and cheaper.
+         PatternEngine.isInitializingGlobals = true;
+
+         Expression.beginProfileLoad(profile);
+
+         try
+         {
+            engine.initialize(profile);
+         }
+         finally
+         {
+            Expression.endProfileLoad();
+         }
+
+         AstSymbolResolver resolver = AstSymbolResolver.getResolver();
+
+         engine.getBaseRuleContainer().warm(resolver);
+
+         return true;
+      }
+      catch (Throwable thr)
+      {
+         skipped++;
+
+         if (VERBOSE)
+         {
+            System.out.println("   skipped profile " + profile + ": " + thr);
+         }
+
+         return false;
+      }
+      finally
+      {
+         PatternEngine.isInitializingGlobals = false;
+
+         try
+         {
+            engine.finish();
+         }
+         catch (Throwable thr)
+         {
+            // teardown trouble does not invalidate what was already generated
+         }
+      }
+   }
+
+   /**
+    * Not instantiable.
+    */
+   private AotWarmer()
+   {
+   }
+}
diff -Nru old/src/com/goldencode/p2j/pattern/AstSymbolResolver.java new/src/com/goldencode/p2j/pattern/AstSymbolResolver.java
--- old/src/com/goldencode/p2j/pattern/AstSymbolResolver.java	2026-09-25 09:27:23.822968771 +0300
+++ new/src/com/goldencode/p2j/pattern/AstSymbolResolver.java	2026-09-25 09:27:23.847808824 +0300
@@ -571,6 +571,11 @@
             break;
       }
       
+      // Every expression belonging to a rule set passes through here, in the single threaded,
+      // document order sequence of a profile load, so this is the one place which can give an
+      // occurrence the durable name its generated class is indexed under.
+      expr.assignAotId();
+      
       return expr;
    }
    
@@ -1298,6 +1303,27 @@
    {
       return context.get().ruleScope;
    }
+
+   /**
+    * Make a named function the innermost scope, as invoking it would.  Used when generating the
+    * expression classes of the shipped rule sets, where a function's expressions must be compiled
+    * in the function's own scope but must not be executed.
+    *
+    * @param   func
+    *          Function to push.
+    */
+   void pushFunctionScope(NamedFunction func)
+   {
+      context.get().funcScopes.push(func);
+   }
+
+   /**
+    * Undo a {@link #pushFunctionScope}.
+    */
+   void popFunctionScope()
+   {
+      context.get().funcScopes.pop();
+   }
    
    /**
     * Set the current source and copy ASTs which will form the basis of all
diff -Nru old/src/com/goldencode/p2j/pattern/NamedFunction.java new/src/com/goldencode/p2j/pattern/NamedFunction.java
--- old/src/com/goldencode/p2j/pattern/NamedFunction.java	2026-09-25 09:27:23.827642673 +0300
+++ new/src/com/goldencode/p2j/pattern/NamedFunction.java	2026-09-25 09:27:23.848116304 +0300
@@ -362,6 +362,49 @@
    }
    
    /**
+    * Force every expression in this function's body to be compiled in the function's own scope,
+    * without executing anything.  Used when generating the expression classes of the shipped rule
+    * sets.
+    *
+    * @param   resolver
+    *          AST symbol resolver.
+    * @param   callerScope
+    *          Scope which encloses this function.
+    */
+   void warm(AstSymbolResolver resolver, Scope callerScope)
+   {
+      enclosingScopes.push(callerScope);
+      resolver.pushFunctionScope(this);
+
+      try
+      {
+         for (Object item : expr)
+         {
+            if (item instanceof Expression)
+            {
+               try
+               {
+                  ((Expression) item).warm();
+               }
+               catch (Throwable thr)
+               {
+                  AotWarmer.failed((Expression) item, thr);
+               }
+            }
+            else if (item instanceof Rule)
+            {
+               ((Rule) item).warm(resolver);
+            }
+         }
+      }
+      finally
+      {
+         resolver.popFunctionScope();
+         enclosingScopes.pop();
+      }
+   }
+
+   /**
     * Execute this named function, handling all user provided parameter
     * assignments before the actual execution.  The execution proceeds
     * through all expressions (defined via {@link #registerExpression}) and
diff -Nru old/src/com/goldencode/p2j/pattern/PatternEngine.java new/src/com/goldencode/p2j/pattern/PatternEngine.java
--- old/src/com/goldencode/p2j/pattern/PatternEngine.java	2026-09-25 09:27:23.832068413 +0300
+++ new/src/com/goldencode/p2j/pattern/PatternEngine.java	2026-09-25 09:27:23.848306731 +0300
@@ -324,6 +324,8 @@
 
 import com.goldencode.artifacts.*;
 import com.goldencode.ast.*;
+// single type import:  a wildcard here would make SymbolResolver ambiguous against p2j.uast
+import com.goldencode.expr.Expression;
 import com.goldencode.p2j.cfg.*;
 import com.goldencode.p2j.convert.*;
 import com.goldencode.p2j.security.*;
@@ -1089,7 +1091,19 @@
       try
       {
          isInitializingGlobals = true;
-         initialize(profile);
+         
+         // Name every expression created while this profile's rule sets are read, so that an
+         // occurrence can be matched to the class generated for it when FWD was built.
+         Expression.beginProfileLoad(profile);
+
+         try
+         {
+            initialize(profile);
+         }
+         finally
+         {
+            Expression.endProfileLoad();
+         }
 
          baseRuleContainer.applyGlobal(RULE_INIT);
 
diff -Nru old/src/com/goldencode/p2j/pattern/RuleContainer.java new/src/com/goldencode/p2j/pattern/RuleContainer.java
--- old/src/com/goldencode/p2j/pattern/RuleContainer.java	2026-09-25 09:27:23.841455168 +0300
+++ new/src/com/goldencode/p2j/pattern/RuleContainer.java	2026-09-25 09:27:23.849021154 +0300
@@ -619,6 +619,58 @@
    }
 
    /**
+    * Force every expression in this container, its functions and its includes to be compiled,
+    * each with its own scope in force, without executing anything.  Used when generating the
+    * expression classes of the shipped rule sets.
+    *
+    * @param   resolver
+    *          AST symbol resolver.
+    */
+   void warm(AstSymbolResolver resolver)
+   {
+      RuleContainer previous = resolver.getRuleScope();
+
+      resolver.setRuleScope(this);
+
+      try
+      {
+         List<RuleListElement> list = ruleList();
+
+         if (list != null)
+         {
+            for (RuleListElement next : list)
+            {
+               AotWarmer.warm(next, resolver);
+            }
+         }
+
+         Map<String, NamedFunction> funcs = getFunctionLibrary();
+
+         if (funcs != null)
+         {
+            for (NamedFunction next : funcs.values())
+            {
+               next.warm(resolver, this);
+            }
+         }
+
+         List<RuleContainer> incs = getIncludes();
+
+         if (incs != null)
+         {
+            for (RuleContainer next : incs)
+            {
+               next.warm(resolver);
+            }
+         }
+      }
+      finally
+      {
+         resolver.setRuleScope(previous);
+      }
+   }
+
+   /**
     * Termination hook to allow resources to be cleaned up when this object is
     * about to go out of service.
     * <p>
diff -Nru old/src/com/goldencode/p2j/pattern/Rule.java new/src/com/goldencode/p2j/pattern/Rule.java
--- old/src/com/goldencode/p2j/pattern/Rule.java	2026-09-25 09:27:23.836989746 +0300
+++ new/src/com/goldencode/p2j/pattern/Rule.java	2026-09-25 09:27:23.848805321 +0300
@@ -584,6 +584,54 @@
    }
    
    /**
+    * Force this rule's condition and every expression beneath it to be compiled, without executing
+    * anything.  Used when generating the expression classes of the shipped rule sets.
+    *
+    * @param   resolver
+    *          AST symbol resolver, with this rule's container scope already in force.
+    */
+   void warm(AstSymbolResolver resolver)
+   {
+      if (condition != null)
+      {
+         try
+         {
+            condition.warm();
+         }
+         catch (Throwable thr)
+         {
+            // an expression which cannot be compiled in isolation is simply left out of the
+            // generated set;  a conversion which reaches it compiles it as it always did
+            AotWarmer.failed(condition, thr);
+         }
+      }
+
+      warmAll(trueActions, resolver);
+      warmAll(falseActions, resolver);
+   }
+
+   /**
+    * Force a list of rule elements to be compiled.
+    *
+    * @param   elements
+    *          Elements to warm, possibly <code>null</code>.
+    * @param   resolver
+    *          AST symbol resolver.
+    */
+   private static void warmAll(List<RuleListElement> elements, AstSymbolResolver resolver)
+   {
+      if (elements == null)
+      {
+         return;
+      }
+
+      for (RuleListElement next : elements)
+      {
+         AotWarmer.warm(next, resolver);
+      }
+   }
+
+   /**
     * Termination hook to allow resources to be cleaned up when this object is
     * about to go out of service.
     * <p>
