values = new LinkedHashMap<>();
+ for (int i = 0; i < node.getKeys().size(); i++) {
+ values.put(
+ node.getKeys().get(i),
+ plain(node.getValues().get(i)));
+ }
+ return Collections.unmodifiableMap(values);
+ default:
+ return null;
+ }
+ }
+}
diff --git a/pipeline/pipeline.derivedproperty/src/main/java/fiftyone/pipeline/derivedproperty/flowelements/DerivedPropertyElement.java b/pipeline/pipeline.derivedproperty/src/main/java/fiftyone/pipeline/derivedproperty/flowelements/DerivedPropertyElement.java
new file mode 100644
index 000000000..1dde7e040
--- /dev/null
+++ b/pipeline/pipeline.derivedproperty/src/main/java/fiftyone/pipeline/derivedproperty/flowelements/DerivedPropertyElement.java
@@ -0,0 +1,566 @@
+/* *********************************************************************
+ * This Original Work is copyright of 51 Degrees Mobile Experts Limited.
+ * Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House,
+ * Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU.
+ *
+ * This Original Work is licensed under the European Union Public Licence
+ * (EUPL) v.1.2 and is subject to its terms as set out below.
+ *
+ * If a copy of the EUPL was not distributed with this file, You can obtain
+ * one at https://opensource.org/licenses/EUPL-1.2.
+ *
+ * The 'Compatible Licences' set out in the Appendix to the EUPL (as may be
+ * amended by the European Commission) shall be deemed incompatible for
+ * the purposes of the Work and the provisions of the compatibility
+ * clause in Article 5 of the EUPL shall not apply.
+ *
+ * If using the Work as, or as part of, a network application, by
+ * including the attribution notice(s) required under Article 5 of the EUPL
+ * in the end user terms of the application under an appropriate heading,
+ * such notice(s) shall fulfill the requirements of that article.
+ * ********************************************************************* */
+
+package fiftyone.pipeline.derivedproperty.flowelements;
+
+import fiftyone.pipeline.core.data.ElementData;
+import fiftyone.pipeline.core.data.ElementPropertyMetaData;
+import fiftyone.pipeline.core.data.ElementPropertyMetaDataDefault;
+import fiftyone.pipeline.core.data.EvidenceKeyFilter;
+import fiftyone.pipeline.core.data.EvidenceKeyFilterWhitelist;
+import fiftyone.pipeline.core.data.FlowData;
+import fiftyone.pipeline.core.data.TryGetResult;
+import fiftyone.pipeline.core.data.factories.ElementDataFactory;
+import fiftyone.pipeline.core.exceptions.PipelineConfigurationException;
+import fiftyone.pipeline.core.flowelements.FlowElement;
+import fiftyone.pipeline.core.flowelements.FlowElementBase;
+import fiftyone.pipeline.core.flowelements.Pipeline;
+import fiftyone.pipeline.core.typed.TypedKey;
+import fiftyone.pipeline.core.typed.TypedKeyDefault;
+import fiftyone.pipeline.derivedproperty.data.IDerivedPropertyData;
+import fiftyone.pipeline.engines.data.AspectPropertyValue;
+import fiftyone.pipeline.engines.data.AspectPropertyValueDefault;
+import org.slf4j.Logger;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.IdentityHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeSet;
+
+/**
+ * The Derived Property Element, which produces new property values from
+ * property values other Flow Elements have already produced, following
+ * rules written in a script, and writes them into the Element Data under
+ * the key {@code derived}, which every instance in a Pipeline shares.
+ *
+ * The element holds no data file, makes no request over the network and
+ * reads no evidence. Everything it holds is fixed when it is built, so
+ * concurrent requests need nothing further, and per request it reads each
+ * source property once, then runs each script.
+ *
+ * See the
+ * Derived
+ * Property Element specification .
+ */
+public final class DerivedPropertyElement
+ extends FlowElementBase
+ implements IDerivedPropertyElement {
+
+ /**
+ * The Element Data key every Derived Property Element in a Pipeline
+ * writes under.
+ */
+ public static final String ELEMENT_DATA_KEY = "derived";
+
+ /**
+ * The sentence that closes every message for a derived property with no
+ * value, being USUAL_CAUSES in tools/evaluate.mjs.
+ */
+ static final String USUAL_CAUSES =
+ "Usual causes are the element that supplies the property not being " +
+ "in the pipeline, or being added after this element rather than " +
+ "before it, the property being excluded in the engine " +
+ "configuration, the property not being included in the resource " +
+ "key, or JavaScript that populates the property not having run yet.";
+
+ private static final TypedKey DATA_KEY =
+ new TypedKeyDefault<>(ELEMENT_DATA_KEY, IDerivedPropertyData.class);
+
+ private final CompiledScript[] scripts;
+ private final SourceRead[] reads;
+ private final List> dataKeys;
+ private final List properties;
+ private final List metadata;
+ private final EvidenceKeyFilter evidenceKeyFilter =
+ new EvidenceKeyFilterWhitelist(Collections.emptyList());
+
+ /**
+ * Construct the element from validated scripts. Use
+ * {@link DerivedPropertyElementBuilder} to build one.
+ * @param logger used for logging
+ * @param dataFactory makes the Element Data
+ * @param models the validated scripts
+ */
+ DerivedPropertyElement(
+ Logger logger,
+ ElementDataFactory dataFactory,
+ List models) {
+ super(logger, dataFactory);
+ Map keyIndex = new HashMap<>();
+ Map readIndex = new HashMap<>();
+ List> keys = new ArrayList<>();
+ List allReads = new ArrayList<>();
+ List compiled = new ArrayList<>();
+ List outputs = new ArrayList<>();
+ List scriptData = new ArrayList<>();
+ for (DerivedScript model : models) {
+ int[] scriptReads = new int[model.getProperties().size()];
+ for (int i = 0; i < scriptReads.length; i++) {
+ SourceProperty property = model.getProperties().get(i);
+ String id = property.getKey() + "|" + property.getType();
+ Integer index = readIndex.get(id);
+ if (index == null) {
+ int key = -1;
+ if (property.getElementKey().equals(ELEMENT_DATA_KEY) ==
+ false) {
+ Integer known = keyIndex.get(property.getElementKey());
+ if (known == null) {
+ known = keys.size();
+ keyIndex.put(property.getElementKey(), known);
+ keys.add(new TypedKeyDefault(
+ property.getElementKey(), ElementData.class));
+ }
+ key = known;
+ }
+ index = allReads.size();
+ readIndex.put(id, index);
+ allReads.add(new SourceRead(property, key));
+ }
+ scriptReads[i] = index;
+ }
+ compiled.add(new CompiledScript(model, scriptReads));
+ scriptData.add(new DerivedPropertyMetaData(model));
+ OutputModel output = model.getOutput();
+ outputs.add(new ElementPropertyMetaDataDefault(
+ output.getName(),
+ this,
+ output.getCategory() == null ? "" : output.getCategory(),
+ output.getValueType().getValueClass(),
+ true));
+ }
+ this.scripts = compiled.toArray(new CompiledScript[0]);
+ this.reads = allReads.toArray(new SourceRead[0]);
+ this.dataKeys = Collections.unmodifiableList(keys);
+ this.properties = Collections.unmodifiableList(outputs);
+ this.metadata = Collections.unmodifiableList(scriptData);
+ }
+
+ @Override
+ public String getElementDataKey() {
+ return ELEMENT_DATA_KEY;
+ }
+
+ @Override
+ public TypedKey getTypedDataKey() {
+ return DATA_KEY;
+ }
+
+ /**
+ * The element reads properties other elements produce and nothing from
+ * the request itself, so it accepts no evidence.
+ * @return a filter that includes no key
+ */
+ @Override
+ public EvidenceKeyFilter getEvidenceKeyFilter() {
+ return evidenceKeyFilter;
+ }
+
+ /**
+ * @return one entry per script, with the output property's name, the
+ * Java type of its value, the category from the script's output block,
+ * and available true
+ */
+ @Override
+ public List getProperties() {
+ return properties;
+ }
+
+ @Override
+ public List getScripts() {
+ return metadata;
+ }
+
+ /**
+ * Every source property the element reads from another element's
+ * Element Data, once each.
+ */
+ List sourceProperties() {
+ List result = new ArrayList<>();
+ for (SourceRead read : reads) {
+ if (read.getKey() >= 0) {
+ result.add(read.getProperty());
+ }
+ }
+ return result;
+ }
+
+ /**
+ * Read each source property once, then run each script, writing one
+ * value, or one value with no value carrying a message, per script.
+ */
+ @Override
+ protected void processInternal(FlowData data) {
+ IDerivedPropertyData out = data.getOrAdd(DATA_KEY, getDataFactory());
+ ElementData[] sources = new ElementData[dataKeys.size()];
+ for (int i = 0; i < sources.length; i++) {
+ TryGetResult source =
+ data.tryGetValue(dataKeys.get(i));
+ if (source.hasValue()) {
+ sources[i] = source.getValue();
+ }
+ }
+ Slot[] slots = new Slot[reads.length];
+ for (int i = 0; i < reads.length; i++) {
+ if (reads[i].getKey() >= 0) {
+ slots[i] = SourceValues.read(
+ sources[reads[i].getKey()], reads[i].getProperty());
+ }
+ }
+ for (CompiledScript script : scripts) {
+ processScript(script, slots, sources, out);
+ }
+ }
+
+ private void processScript(
+ CompiledScript script,
+ Slot[] slots,
+ ElementData[] sources,
+ IDerivedPropertyData out) {
+ DerivedScript model = script.getModel();
+ Slot[] scriptSlots = new Slot[script.readCount()];
+ List missing = null;
+ List reasons = null;
+ SourceRead noData = null;
+ for (int i = 0; i < scriptSlots.length; i++) {
+ SourceRead read = reads[script.read(i)];
+ if (read.getKey() < 0) {
+ scriptSlots[i] = SourceValues.read(out, read.getProperty());
+ } else {
+ scriptSlots[i] = slots[script.read(i)];
+ if (sources[read.getKey()] == null && noData == null) {
+ noData = read;
+ }
+ }
+ if (scriptSlots[i].isAvailable() == false) {
+ if (missing == null) {
+ missing = new ArrayList<>();
+ reasons = new ArrayList<>();
+ }
+ missing.add(model.getProperties().get(i).getName());
+ reasons.add(scriptSlots[i].getReason());
+ }
+ }
+ OutputModel output = model.getOutput();
+ if (missing != null) {
+ if (noData != null && script.firstMissingData()) {
+ String key = noData.getProperty().getElementKey();
+ logger.error("Derived property script '" + model.getName() +
+ "' reads '" + noData.getProperty().getName() + "', and a " +
+ "request carried no element data under '" + key + "'. " +
+ "Add this element to the pipeline after the element " +
+ "that supplies '" + key + "'. This error is reported " +
+ "once for this script.");
+ }
+ AspectPropertyValueDefault value =
+ new AspectPropertyValueDefault<>();
+ value.setNoValueMessage(
+ missingMessage(output.getName(), missing, reasons));
+ out.put(output.getName(), value);
+ return;
+ }
+ out.put(output.getName(),
+ valueOf(output.getValueType(), model.evaluate(scriptSlots)));
+ }
+
+ private static AspectPropertyValue> valueOf(
+ ValueType type,
+ Literal literal) {
+ switch (type) {
+ case BOOL:
+ return new AspectPropertyValueDefault<>(literal.getBool());
+ case INT:
+ return new AspectPropertyValueDefault<>(
+ (int) literal.getNumber());
+ case DOUBLE:
+ return new AspectPropertyValueDefault<>(literal.getNumber());
+ default:
+ return new AspectPropertyValueDefault<>(literal.getText());
+ }
+ }
+
+ /**
+ * Say why a script has no value, naming every source property that
+ * could not be read, in the wording of missingMessage in
+ * tools/evaluate.mjs.
+ */
+ static String missingMessage(
+ String outputName,
+ List missing,
+ List reasons) {
+ StringBuilder message = new StringBuilder()
+ .append("Derived property '").append(outputName)
+ .append("' has no value because ");
+ if (missing.size() == 1) {
+ message.append("1 source property was not available.");
+ } else {
+ message.append(missing.size())
+ .append(" source properties were not available.");
+ }
+ for (int i = 0; i < missing.size(); i++) {
+ message.append(" '").append(missing.get(i)).append("' (")
+ .append(reasons.get(i)).append(").");
+ }
+ return message.append(' ').append(USUAL_CAUSES).toString();
+ }
+
+ /**
+ * Check that some element in the Pipeline supplies every source
+ * property a script names, and that no two Derived Property Elements
+ * produce the same property. Where an element sits is not judged,
+ * because a Pipeline lists the members of a parallel group after the
+ * elements at the top level, so a position does not say what ran
+ * first.
+ *
+ * See the
+ * Pipeline
+ * check .
+ * @param pipeline the Pipeline the element is being added to
+ * @throws PipelineConfigurationException where the check fails
+ */
+ @Override
+ public void addPipeline(Pipeline pipeline) {
+ if (pipeline != null) {
+ checkPipeline(pipeline);
+ }
+ super.addPipeline(pipeline);
+ }
+
+ private void checkPipeline(Pipeline pipeline) {
+ Map> available =
+ availableProperties(pipeline);
+ List> elements = elementsOf(pipeline, available);
+ Set keys = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
+ keys.addAll(available.keySet());
+ boolean allLoaded = allLoaded(pipeline);
+ List problems = new ArrayList<>();
+ for (CompiledScript script : scripts) {
+ for (SourceProperty property :
+ script.getModel().getProperties()) {
+ String problem = checkSupplied(elements, keys, allLoaded,
+ script.getModel().getName(), property);
+ if (problem != null) {
+ problems.add(problem);
+ }
+ }
+ }
+ // Each name counts the distinct Derived Property Elements that
+ // produce it.
+ Map producers = new HashMap<>();
+ for (FlowElement, ?> element : elements) {
+ if (element instanceof DerivedPropertyElement) {
+ for (CompiledScript script :
+ ((DerivedPropertyElement) element).scripts) {
+ String name = script.getModel().getOutput().getName()
+ .toLowerCase(Locale.ROOT);
+ Integer count = producers.get(name);
+ producers.put(name, count == null ? 1 : count + 1);
+ }
+ }
+ }
+ for (CompiledScript script : scripts) {
+ String name = script.getModel().getOutput().getName();
+ Integer count = producers.get(name.toLowerCase(Locale.ROOT));
+ if (count != null && count > 1) {
+ problems.add("two Derived Property Elements in the pipeline " +
+ "produce the derived property '" + name + "'");
+ }
+ }
+ if (problems.isEmpty() == false) {
+ throw new PipelineConfigurationException(
+ String.join(". ", problems));
+ }
+ }
+
+ /**
+ * Why no element supplies a source property, or null where one does.
+ * An element whose properties are not known yet, such as a cloud engine
+ * that could not reach the service, is taken to supply the properties
+ * under its key, with a warning, so that a passing failure does not
+ * stop the Pipeline being built.
+ */
+ private String checkSupplied(
+ List> elements,
+ Set keys,
+ boolean allLoaded,
+ String script,
+ SourceProperty property) {
+ String key = property.getElementKey();
+ String name = property.getPropertyName();
+ boolean inspected = false;
+ for (FlowElement, ?> element : elements) {
+ if (element.getElementDataKey().equalsIgnoreCase(key) == false) {
+ continue;
+ }
+ inspected = true;
+ if (lists(element, name)) {
+ return null;
+ }
+ if (element.hasLoadedProperties() == false) {
+ warnNotLoaded(script, property);
+ return null;
+ }
+ }
+ if (inspected == false && keys.contains(key) == false) {
+ return "derived property script '" + script + "' reads '" +
+ property.getName() + "', and no element in the pipeline has " +
+ "the element data key '" + key + "'";
+ }
+ if (inspected == false && allLoaded == false) {
+ warnNotLoaded(script, property);
+ return null;
+ }
+ return "derived property script '" + script + "' reads '" +
+ property.getName() + "', and no element under '" + key +
+ "' lists the property '" + name + "'";
+ }
+
+ private void warnNotLoaded(String script, SourceProperty property) {
+ logger.warn("Derived property script '" + script + "' reads '" +
+ property.getName() + "', and the element under '" +
+ property.getElementKey() + "' has not loaded its properties " +
+ "yet, so whether it supplies '" + property.getPropertyName() +
+ "' cannot be checked.");
+ }
+
+ /**
+ * Whether an element lists a property in its metadata, matched without
+ * regard to case.
+ */
+ private static boolean lists(FlowElement, ?> element, String name) {
+ try {
+ for (ElementPropertyMetaData property : element.getProperties()) {
+ if (property.getName().equalsIgnoreCase(name)) {
+ return true;
+ }
+ }
+ } catch (RuntimeException e) {
+ return false;
+ }
+ return false;
+ }
+
+ /**
+ * The Pipeline's available properties by Element Data key, which covers
+ * the members of every parallel group as well as the elements at the
+ * top level.
+ */
+ private static Map>
+ availableProperties(Pipeline pipeline) {
+ try {
+ return pipeline.getElementAvailableProperties();
+ } catch (RuntimeException e) {
+ return Collections.emptyMap();
+ }
+ }
+
+ /**
+ * Every element of the Pipeline that has an Element Data key, with the
+ * members of a parallel group found through the available properties,
+ * whose metadata names the element that owns each property.
+ */
+ private static List> elementsOf(
+ Pipeline pipeline,
+ Map> available) {
+ Map, Boolean> seen = new IdentityHashMap<>();
+ List> elements = new ArrayList<>();
+ for (Object top : pipeline.getFlowElements()) {
+ addElement((FlowElement, ?>) top, seen, elements);
+ }
+ for (Map byName :
+ available.values()) {
+ for (ElementPropertyMetaData property : byName.values()) {
+ FlowElement, ?> owner = property.getElement();
+ if (owner != null) {
+ addElement(owner, seen, elements);
+ }
+ }
+ }
+ return elements;
+ }
+
+ /**
+ * Add an element that has an Element Data key. A parallel group has
+ * none, and is passed over.
+ */
+ private static void addElement(
+ FlowElement, ?> element,
+ Map, Boolean> seen,
+ List> elements) {
+ if (seen.containsKey(element)) {
+ return;
+ }
+ seen.put(element, Boolean.TRUE);
+ try {
+ element.getElementDataKey();
+ } catch (UnsupportedOperationException e) {
+ return;
+ }
+ elements.add(element);
+ }
+
+ private static boolean allLoaded(Pipeline pipeline) {
+ for (Object element : pipeline.getFlowElements()) {
+ if (((FlowElement, ?>) element).hasLoadedProperties() == false) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ @Override
+ protected void managedResourcesCleanup() {
+ }
+
+ @Override
+ protected void unmanagedResourcesCleanup() {
+ }
+
+ /**
+ * Write the build log for each script, being one information line
+ * giving the name, the version, the format, the source and the output
+ * property, the compiled model as canonical JSON at debug level, and a
+ * warning carrying the note of a deprecated script.
+ */
+ void logScripts() {
+ for (CompiledScript script : scripts) {
+ DerivedScript model = script.getModel();
+ logger.info("Derived property script '" + model.getName() +
+ "' version " + model.getVersion() + ", format " +
+ model.getFormat() + ", from " + model.getSource() +
+ ", produces '" + ELEMENT_DATA_KEY + "." +
+ model.getOutput().getName() + "'");
+ if (logger.isDebugEnabled()) {
+ logger.debug("Derived property script '" + model.getName() +
+ "' compiled to:\n" + CanonicalJson.print(model));
+ }
+ if (model.isDeprecated()) {
+ logger.warn("Derived property script '" + model.getName() +
+ "' is deprecated. " + model.getDeprecationNote());
+ }
+ }
+ }
+}
diff --git a/pipeline/pipeline.derivedproperty/src/main/java/fiftyone/pipeline/derivedproperty/flowelements/DerivedPropertyElementBuilder.java b/pipeline/pipeline.derivedproperty/src/main/java/fiftyone/pipeline/derivedproperty/flowelements/DerivedPropertyElementBuilder.java
new file mode 100644
index 000000000..492f753ac
--- /dev/null
+++ b/pipeline/pipeline.derivedproperty/src/main/java/fiftyone/pipeline/derivedproperty/flowelements/DerivedPropertyElementBuilder.java
@@ -0,0 +1,341 @@
+/* *********************************************************************
+ * This Original Work is copyright of 51 Degrees Mobile Experts Limited.
+ * Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House,
+ * Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU.
+ *
+ * This Original Work is licensed under the European Union Public Licence
+ * (EUPL) v.1.2 and is subject to its terms as set out below.
+ *
+ * If a copy of the EUPL was not distributed with this file, You can obtain
+ * one at https://opensource.org/licenses/EUPL-1.2.
+ *
+ * The 'Compatible Licences' set out in the Appendix to the EUPL (as may be
+ * amended by the European Commission) shall be deemed incompatible for
+ * the purposes of the Work and the provisions of the compatibility
+ * clause in Article 5 of the EUPL shall not apply.
+ *
+ * If using the Work as, or as part of, a network application, by
+ * including the attribution notice(s) required under Article 5 of the EUPL
+ * in the end user terms of the application under an appropriate heading,
+ * such notice(s) shall fulfill the requirements of that article.
+ * ********************************************************************* */
+
+package fiftyone.pipeline.derivedproperty.flowelements;
+
+import fiftyone.pipeline.annotations.ElementBuilder;
+import fiftyone.pipeline.derivedproperty.BuiltInScript;
+import fiftyone.pipeline.derivedproperty.DerivedScriptFault;
+import fiftyone.pipeline.derivedproperty.DerivedScriptValidationException;
+import fiftyone.pipeline.derivedproperty.data.DerivedPropertyData;
+import fiftyone.pipeline.derivedproperty.data.IDerivedPropertyData;
+import org.slf4j.ILoggerFactory;
+import org.slf4j.Logger;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.DirectoryIteratorException;
+import java.nio.file.DirectoryStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+/**
+ * Builds a {@link DerivedPropertyElement} from scripts given in three ways,
+ * which can be mixed, being scripts shipped in this package, files on disk
+ * and text in code. There is no way to fetch a script over the network.
+ *
+ * Every script is parsed and validated when {@link #build()} is called,
+ * and every fault in every script is reported at once, in one
+ * {@link DerivedScriptValidationException}.
+ *
+ * A configuration file names this builder as
+ * {@code DerivedPropertyElement}, with {@code Scripts} naming built-in
+ * scripts and {@code ScriptFiles} naming files, each a comma separated
+ * list. Script text from code is not available from configuration.
+ */
+@ElementBuilder
+public class DerivedPropertyElementBuilder {
+
+ /**
+ * The characters that make a file name a pattern.
+ */
+ private static final String WILDCARDS = "*?[{";
+
+ /**
+ * A script as given, before it is validated.
+ */
+ private static final class ScriptText {
+ private final String name;
+ private final String text;
+ private final String source;
+
+ ScriptText(String name, String text, String source) {
+ this.name = name;
+ this.text = text;
+ this.source = source;
+ }
+ }
+
+ private final ILoggerFactory loggerFactory;
+ private final List scripts = new ArrayList<>();
+ private final List faults = new ArrayList<>();
+
+ /**
+ * Construct a new builder.
+ * @param loggerFactory the logger factory for the element and the
+ * Element Data it creates
+ */
+ public DerivedPropertyElementBuilder(ILoggerFactory loggerFactory) {
+ if (loggerFactory == null) {
+ throw new IllegalArgumentException("loggerFactory");
+ }
+ this.loggerFactory = loggerFactory;
+ }
+
+ /**
+ * Add a script shipped in this package.
+ * @param script the script
+ * @return this builder
+ */
+ public DerivedPropertyElementBuilder addScript(BuiltInScript script) {
+ String text = script.readText();
+ if (text == null) {
+ faults.add(new DerivedScriptFault(script.name(), script.name(),
+ "", 0, "the built-in script '" + script.name() + "' is not " +
+ "in this package, which was built without the " +
+ "derived-properties scripts"));
+ return this;
+ }
+ scripts.add(new ScriptText(script.name(), text, script.name()));
+ return this;
+ }
+
+ /**
+ * Add every file the path matches. The file name may hold the wildcards
+ * {@code *} and {@code ?}, as in {@code derived/*.yaml}. A script's Name
+ * must equal its file name without the extension. A path matching no
+ * file is a fault.
+ * @param path the path, relative to the working directory or absolute
+ * @return this builder
+ */
+ public DerivedPropertyElementBuilder addScriptFile(String path) {
+ List matches = matches(path);
+ if (matches.isEmpty()) {
+ faults.add(new DerivedScriptFault("", path, "", 0,
+ "no file matches '" + path + "'"));
+ return this;
+ }
+ for (Path match : matches) {
+ String fileName = match.getFileName().toString();
+ int dot = fileName.lastIndexOf('.');
+ String name = dot > 0 ? fileName.substring(0, dot) : fileName;
+ try {
+ String text = new String(
+ Files.readAllBytes(match), StandardCharsets.UTF_8);
+ scripts.add(new ScriptText(name, text, match.toString()));
+ } catch (IOException e) {
+ faults.add(new DerivedScriptFault(name, match.toString(), "",
+ 0, "the file cannot be read: " + e.getMessage()));
+ }
+ }
+ return this;
+ }
+
+ /**
+ * Add a script from text in code, read as YAML or JSON as the text
+ * shows.
+ * @param name the name the script must have
+ * @param content the text of the script
+ * @return this builder
+ */
+ public DerivedPropertyElementBuilder addScript(
+ String name,
+ String content) {
+ scripts.add(new ScriptText(name, content, "code"));
+ return this;
+ }
+
+ /**
+ * Add built-in scripts by name, which is how the {@code Scripts} build
+ * parameter of a configuration file reaches the builder.
+ * @param names the names, separated by commas
+ * @return this builder
+ */
+ public DerivedPropertyElementBuilder setScripts(String names) {
+ for (String name : split(names)) {
+ BuiltInScript script = BuiltInScript.fromName(name);
+ if (script == null) {
+ faults.add(new DerivedScriptFault(name, name, "", 0, "'" +
+ name + "' is not a built-in script. The built-in " +
+ "scripts are " + BuiltInScript.names()));
+ } else {
+ addScript(script);
+ }
+ }
+ return this;
+ }
+
+ /**
+ * Add script files by path, which is how the {@code ScriptFiles} build
+ * parameter of a configuration file reaches the builder.
+ * @param paths the paths, separated by commas, each of which may hold
+ * wildcards in its file name
+ * @return this builder
+ */
+ public DerivedPropertyElementBuilder setScriptFiles(String paths) {
+ for (String path : split(paths)) {
+ addScriptFile(path);
+ }
+ return this;
+ }
+
+ /**
+ * Validate every script and build the element.
+ * @return a new element
+ * @throws DerivedScriptValidationException listing every fault in every
+ * script, where any script has one
+ * @throws IllegalStateException where no script was added
+ */
+ public DerivedPropertyElement build() {
+ if (scripts.isEmpty() && faults.isEmpty()) {
+ throw new IllegalStateException(
+ "A Derived Property Element needs at least one script. Add " +
+ "one with addScript or addScriptFile, or name one with the " +
+ "Scripts or ScriptFiles build parameter.");
+ }
+ List all = new ArrayList<>(faults);
+ List models = new ArrayList<>();
+ for (ScriptText script : scripts) {
+ DerivedScriptValidator.Result result = DerivedScriptValidator
+ .validate(script.text, script.name, script.source);
+ all.addAll(result.getFaults());
+ if (result.getScript() != null) {
+ models.add(result.getScript());
+ }
+ }
+ all.addAll(duplicates(models));
+ if (all.isEmpty() == false) {
+ throw new DerivedScriptValidationException(all);
+ }
+ Logger dataLogger =
+ loggerFactory.getLogger(DerivedPropertyData.class.getName());
+ DerivedPropertyElement element = new DerivedPropertyElement(
+ loggerFactory.getLogger(DerivedPropertyElement.class.getName()),
+ (flowData, flowElement) ->
+ new DerivedPropertyData(dataLogger, flowData),
+ models);
+ element.logScripts();
+ return element;
+ }
+
+ /**
+ * Two scripts with the same name, or producing the same output
+ * property, as there is one script per name and one value per output
+ * property.
+ */
+ private static List duplicates(
+ List models) {
+ List found = new ArrayList<>();
+ Map names = new HashMap<>();
+ Map outputs = new HashMap<>();
+ for (DerivedScript model : models) {
+ String name = model.getName().toLowerCase(Locale.ROOT);
+ DerivedScript first = names.get(name);
+ if (first != null) {
+ found.add(new DerivedScriptFault(model.getName(),
+ model.getSource(), "Name", 0, "the script '" +
+ model.getName() + "' is added more than once, from " +
+ first.getSource() + " and from " + model.getSource()));
+ } else {
+ names.put(name, model);
+ }
+ String output =
+ model.getOutput().getName().toLowerCase(Locale.ROOT);
+ first = outputs.get(output);
+ if (first != null) {
+ found.add(new DerivedScriptFault(model.getName(),
+ model.getSource(), "Output.Name", 0, "the output " +
+ "property '" + model.getOutput().getName() + "' is " +
+ "also produced by the script '" + first.getName() +
+ "' from " + first.getSource()));
+ } else {
+ outputs.put(output, model);
+ }
+ }
+ return found;
+ }
+
+ /**
+ * The entries of a comma separated list, with the white space around
+ * each removed and empty entries left out.
+ */
+ private static List split(String list) {
+ List entries = new ArrayList<>();
+ if (list == null) {
+ return entries;
+ }
+ for (String entry : list.split(",")) {
+ String trimmed = entry.trim();
+ if (trimmed.isEmpty() == false) {
+ entries.add(trimmed);
+ }
+ }
+ return entries;
+ }
+
+ /**
+ * The files a path matches, in name order. Only the file name may hold
+ * wildcards, which are read as a glob.
+ */
+ private static List matches(String pattern) {
+ List found = new ArrayList<>();
+ if (pattern == null || pattern.isEmpty()) {
+ return found;
+ }
+ File file = new File(pattern);
+ String glob = file.getName();
+ try {
+ if (hasWildcard(glob) == false) {
+ Path path = file.toPath();
+ if (Files.isRegularFile(path)) {
+ found.add(path);
+ }
+ return found;
+ }
+ File parent = file.getParentFile();
+ Path directory = parent == null ?
+ new File(".").toPath() : parent.toPath();
+ try (DirectoryStream stream =
+ Files.newDirectoryStream(directory, glob)) {
+ for (Path match : stream) {
+ if (Files.isRegularFile(match)) {
+ found.add(parent == null ?
+ match.getFileName() : match);
+ }
+ }
+ }
+ } catch (IOException | DirectoryIteratorException |
+ IllegalArgumentException e) {
+ // An invalid path or pattern matches no file.
+ return Collections.emptyList();
+ }
+ found.sort(Comparator.comparing(Path::toString));
+ return found;
+ }
+
+ private static boolean hasWildcard(String name) {
+ for (int i = 0; i < name.length(); i++) {
+ if (WILDCARDS.indexOf(name.charAt(i)) >= 0) {
+ return true;
+ }
+ }
+ return false;
+ }
+}
diff --git a/pipeline/pipeline.derivedproperty/src/main/java/fiftyone/pipeline/derivedproperty/flowelements/DerivedPropertyMetaData.java b/pipeline/pipeline.derivedproperty/src/main/java/fiftyone/pipeline/derivedproperty/flowelements/DerivedPropertyMetaData.java
new file mode 100644
index 000000000..614774c7d
--- /dev/null
+++ b/pipeline/pipeline.derivedproperty/src/main/java/fiftyone/pipeline/derivedproperty/flowelements/DerivedPropertyMetaData.java
@@ -0,0 +1,97 @@
+/* *********************************************************************
+ * This Original Work is copyright of 51 Degrees Mobile Experts Limited.
+ * Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House,
+ * Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU.
+ *
+ * This Original Work is licensed under the European Union Public Licence
+ * (EUPL) v.1.2 and is subject to its terms as set out below.
+ *
+ * If a copy of the EUPL was not distributed with this file, You can obtain
+ * one at https://opensource.org/licenses/EUPL-1.2.
+ *
+ * The 'Compatible Licences' set out in the Appendix to the EUPL (as may be
+ * amended by the European Commission) shall be deemed incompatible for
+ * the purposes of the Work and the provisions of the compatibility
+ * clause in Article 5 of the EUPL shall not apply.
+ *
+ * If using the Work as, or as part of, a network application, by
+ * including the attribution notice(s) required under Article 5 of the EUPL
+ * in the end user terms of the application under an appropriate heading,
+ * such notice(s) shall fulfill the requirements of that article.
+ * ********************************************************************* */
+
+package fiftyone.pipeline.derivedproperty.flowelements;
+
+/**
+ * The full Property metadata block of one script a Derived Property Element
+ * holds. Its {@link #getOutput()} is the whole definition of the derived
+ * property.
+ */
+public final class DerivedPropertyMetaData {
+
+ private final String name;
+ private final String version;
+ private final int format;
+ private final boolean deprecated;
+ private final String deprecationNote;
+ private final String source;
+ private final DerivedOutputMetaData output;
+
+ DerivedPropertyMetaData(DerivedScript script) {
+ this.name = script.getName();
+ this.version = script.getVersion();
+ this.format = script.getFormat();
+ this.deprecated = script.isDeprecated();
+ this.deprecationNote = script.getDeprecationNote();
+ this.source = script.getSource();
+ this.output = new DerivedOutputMetaData(script.getOutput());
+ }
+
+ /**
+ * @return the script's name, which is what selects it
+ */
+ public String getName() {
+ return name;
+ }
+
+ /**
+ * @return the author's semantic version, which plays no part in
+ * selecting the script
+ */
+ public String getVersion() {
+ return version;
+ }
+
+ /**
+ * @return the version of the script language, which is 1
+ */
+ public int getFormat() {
+ return format;
+ }
+
+ public boolean isDeprecated() {
+ return deprecated;
+ }
+
+ /**
+ * @return what to use instead, or empty where the script is not
+ * deprecated
+ */
+ public String getDeprecationNote() {
+ return deprecationNote;
+ }
+
+ /**
+ * @return the built-in script's name, the file path, or "code"
+ */
+ public String getSource() {
+ return source;
+ }
+
+ /**
+ * @return the definition of the property the script produces
+ */
+ public DerivedOutputMetaData getOutput() {
+ return output;
+ }
+}
diff --git a/pipeline/pipeline.derivedproperty/src/main/java/fiftyone/pipeline/derivedproperty/flowelements/DerivedScript.java b/pipeline/pipeline.derivedproperty/src/main/java/fiftyone/pipeline/derivedproperty/flowelements/DerivedScript.java
new file mode 100644
index 000000000..6c253198c
--- /dev/null
+++ b/pipeline/pipeline.derivedproperty/src/main/java/fiftyone/pipeline/derivedproperty/flowelements/DerivedScript.java
@@ -0,0 +1,185 @@
+/* *********************************************************************
+ * This Original Work is copyright of 51 Degrees Mobile Experts Limited.
+ * Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House,
+ * Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU.
+ *
+ * This Original Work is licensed under the European Union Public Licence
+ * (EUPL) v.1.2 and is subject to its terms as set out below.
+ *
+ * If a copy of the EUPL was not distributed with this file, You can obtain
+ * one at https://opensource.org/licenses/EUPL-1.2.
+ *
+ * The 'Compatible Licences' set out in the Appendix to the EUPL (as may be
+ * amended by the European Commission) shall be deemed incompatible for
+ * the purposes of the Work and the provisions of the compatibility
+ * clause in Article 5 of the EUPL shall not apply.
+ *
+ * If using the Work as, or as part of, a network application, by
+ * including the attribution notice(s) required under Article 5 of the EUPL
+ * in the end user terms of the application under an appropriate heading,
+ * such notice(s) shall fulfill the requirements of that article.
+ * ********************************************************************* */
+
+package fiftyone.pipeline.derivedproperty.flowelements;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * A validated script, the model every language builds from the same text.
+ * Nothing in it changes once it is built.
+ */
+final class DerivedScript {
+
+ /**
+ * A check, which is a named condition.
+ */
+ static final class NamedCheck {
+ private final String name;
+ private final Condition condition;
+
+ NamedCheck(String name, Condition condition) {
+ this.name = name;
+ this.condition = condition;
+ }
+
+ String getName() {
+ return name;
+ }
+
+ Condition getCondition() {
+ return condition;
+ }
+ }
+
+ /**
+ * A When with a Then, or, where the condition is null, an Else.
+ */
+ static final class Rule {
+ private final Condition when;
+ private final Literal value;
+
+ Rule(Condition when, Literal value) {
+ this.when = when;
+ this.value = value;
+ }
+
+ /**
+ * @return the condition, or null for an Else
+ */
+ Condition getWhen() {
+ return when;
+ }
+
+ Literal getValue() {
+ return value;
+ }
+ }
+
+ private final int format;
+ private final String name;
+ private final String version;
+ private final boolean deprecated;
+ private final String deprecationNote;
+ private final String source;
+ private final OutputModel output;
+ private final List properties;
+ private final List checks;
+ private final List rules;
+
+ /**
+ * @param properties the source properties in the order the script first
+ * names them, checks before rules. A comparison reads
+ * its property from the slot with the same index.
+ */
+ DerivedScript(
+ int format,
+ String name,
+ String version,
+ boolean deprecated,
+ String deprecationNote,
+ String source,
+ OutputModel output,
+ List properties,
+ List checks,
+ List rules) {
+ this.format = format;
+ this.name = name;
+ this.version = version;
+ this.deprecated = deprecated;
+ this.deprecationNote = deprecationNote;
+ this.source = source;
+ this.output = output;
+ this.properties = Collections.unmodifiableList(
+ new ArrayList<>(properties));
+ this.checks = Collections.unmodifiableList(new ArrayList<>(checks));
+ this.rules = Collections.unmodifiableList(new ArrayList<>(rules));
+ }
+
+ int getFormat() {
+ return format;
+ }
+
+ String getName() {
+ return name;
+ }
+
+ String getVersion() {
+ return version;
+ }
+
+ boolean isDeprecated() {
+ return deprecated;
+ }
+
+ String getDeprecationNote() {
+ return deprecationNote;
+ }
+
+ /**
+ * @return the built-in script's name, the file path, or "code"
+ */
+ String getSource() {
+ return source;
+ }
+
+ OutputModel getOutput() {
+ return output;
+ }
+
+ List getProperties() {
+ return properties;
+ }
+
+ List getChecks() {
+ return checks;
+ }
+
+ List getRules() {
+ return rules;
+ }
+
+ /**
+ * Run the script on the source properties read for it. Every slot is
+ * available, so every condition is true or false, and the Else every
+ * script ends in always matches.
+ * @param slots the source properties, in the order of
+ * {@link #getProperties()}
+ * @return the value of the first rule whose condition is true
+ */
+ Literal evaluate(Slot[] slots) {
+ boolean[] results = new boolean[checks.size()];
+ for (int i = 0; i < results.length; i++) {
+ results[i] = checks.get(i).getCondition().test(slots, results);
+ }
+ for (Rule rule : rules) {
+ if (rule.getWhen() == null ||
+ rule.getWhen().test(slots, results)) {
+ return rule.getValue();
+ }
+ }
+ throw new IllegalStateException(
+ "The rules of '" + output.getName() + "' do not end in an Else.");
+ }
+}
diff --git a/pipeline/pipeline.derivedproperty/src/main/java/fiftyone/pipeline/derivedproperty/flowelements/DerivedScriptParser.java b/pipeline/pipeline.derivedproperty/src/main/java/fiftyone/pipeline/derivedproperty/flowelements/DerivedScriptParser.java
new file mode 100644
index 000000000..58cc00e82
--- /dev/null
+++ b/pipeline/pipeline.derivedproperty/src/main/java/fiftyone/pipeline/derivedproperty/flowelements/DerivedScriptParser.java
@@ -0,0 +1,287 @@
+/* *********************************************************************
+ * This Original Work is copyright of 51 Degrees Mobile Experts Limited.
+ * Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House,
+ * Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU.
+ *
+ * This Original Work is licensed under the European Union Public Licence
+ * (EUPL) v.1.2 and is subject to its terms as set out below.
+ *
+ * If a copy of the EUPL was not distributed with this file, You can obtain
+ * one at https://opensource.org/licenses/EUPL-1.2.
+ *
+ * The 'Compatible Licences' set out in the Appendix to the EUPL (as may be
+ * amended by the European Commission) shall be deemed incompatible for
+ * the purposes of the Work and the provisions of the compatibility
+ * clause in Article 5 of the EUPL shall not apply.
+ *
+ * If using the Work as, or as part of, a network application, by
+ * including the attribution notice(s) required under Article 5 of the EUPL
+ * in the end user terms of the application under an appropriate heading,
+ * such notice(s) shall fulfill the requirements of that article.
+ * ********************************************************************* */
+
+package fiftyone.pipeline.derivedproperty.flowelements;
+
+import org.yaml.snakeyaml.LoaderOptions;
+import org.yaml.snakeyaml.Yaml;
+import org.yaml.snakeyaml.error.Mark;
+import org.yaml.snakeyaml.error.MarkedYAMLException;
+import org.yaml.snakeyaml.error.YAMLException;
+import org.yaml.snakeyaml.nodes.MappingNode;
+import org.yaml.snakeyaml.nodes.Node;
+import org.yaml.snakeyaml.nodes.NodeTuple;
+import org.yaml.snakeyaml.nodes.ScalarNode;
+import org.yaml.snakeyaml.nodes.SequenceNode;
+
+import java.io.StringReader;
+import java.math.BigInteger;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.regex.Pattern;
+
+/**
+ * Reads the text of a script, as YAML or as JSON, into {@link ScriptNode}s.
+ *
+ * JSON is read as the JSON subset of YAML 1.2 by the same reader. The
+ * nodes are composed rather than constructed, so every node keeps its line,
+ * and a plain scalar is resolved here by the YAML 1.2 core schema, under
+ * which only {@code true} and {@code false} in their three spellings are
+ * booleans, so {@code yes} and {@code NO} stay strings. A quoted scalar is
+ * always a string.
+ *
+ * See
+ * YAML 1.2.2,
+ * 10.3.2 Tag Resolution and
+ * format
+ * 1, two places where the languages are allowed to differ .
+ */
+final class DerivedScriptParser {
+
+ /**
+ * How deep a value may nest, which only aliases that refer to
+ * themselves can reach, because the reader limits nesting written out
+ * in the text to less than this.
+ */
+ private static final int MAX_DEPTH = 100;
+
+ private static final Pattern DECIMAL_INT = Pattern.compile("[-+]?[0-9]+");
+ private static final Pattern OCTAL_INT = Pattern.compile("0o[0-7]+");
+ private static final Pattern HEX_INT = Pattern.compile("0x[0-9a-fA-F]+");
+ private static final Pattern FLOAT = Pattern.compile(
+ "[-+]?(\\.[0-9]+|[0-9]+(\\.[0-9]*)?)([eE][-+]?[0-9]+)?");
+ private static final Pattern INFINITY =
+ Pattern.compile("[-+]?\\.(inf|Inf|INF)");
+ private static final Pattern NOT_A_NUMBER =
+ Pattern.compile("\\.(nan|NaN|NAN)");
+
+ private static final int OCTAL_RADIX = 8;
+ private static final int HEX_RADIX = 16;
+
+ /**
+ * The length of the 0o or 0x prefix.
+ */
+ private static final int RADIX_PREFIX_LENGTH = 2;
+
+ private DerivedScriptParser() {
+ }
+
+ /**
+ * Parse the text of a script.
+ * @param text the script, as YAML or JSON
+ * @return the document, which is always a mapping
+ * @throws ScriptParseException where the text is not YAML or JSON, is
+ * empty, is not a mapping, or writes a key twice
+ */
+ static ScriptNode parse(String text) throws ScriptParseException {
+ if (text == null) {
+ throw new ScriptParseException("the script is empty", 0);
+ }
+ String what = startsLikeJson(text) ? "JSON" : "YAML";
+ Node root;
+ try {
+ root = new Yaml(new LoaderOptions()).compose(
+ new StringReader(text));
+ } catch (MarkedYAMLException e) {
+ Mark mark = e.getProblemMark() != null ?
+ e.getProblemMark() : e.getContextMark();
+ String problem = e.getProblem() != null ?
+ e.getProblem() : e.getMessage();
+ throw new ScriptParseException(
+ "the text is not valid " + what + ": " + problem,
+ mark == null ? 0 : mark.getLine() + 1);
+ } catch (YAMLException e) {
+ throw new ScriptParseException(
+ "the text is not valid " + what + ": " + e.getMessage(), 0);
+ }
+ if (root == null) {
+ throw new ScriptParseException("the script is empty", 0);
+ }
+ ScriptNode document = convert(root, 0);
+ if (document.getKind() == ScriptNode.Kind.NULL) {
+ throw new ScriptParseException("the script is empty", 0);
+ }
+ if (document.getKind() != ScriptNode.Kind.MAP) {
+ throw new ScriptParseException(
+ "the script must be a mapping of keys to values at the top " +
+ "level",
+ 0);
+ }
+ requireDistinctKeys(document);
+ return document;
+ }
+
+ /**
+ * Whether the text opens with a brace once leading white space is
+ * passed over, which is how the reference tools tell JSON from YAML.
+ */
+ private static boolean startsLikeJson(String text) {
+ for (int i = 0; i < text.length(); i++) {
+ char c = text.charAt(i);
+ if (JsText.isWhitespace(c) == false) {
+ return c == '{';
+ }
+ }
+ return false;
+ }
+
+ private static ScriptNode convert(Node node, int depth)
+ throws ScriptParseException {
+ int line = node.getStartMark() == null ?
+ 0 : node.getStartMark().getLine() + 1;
+ if (depth > MAX_DEPTH) {
+ throw new ScriptParseException(
+ "the text is not valid YAML: aliases nest too deeply", line);
+ }
+ if (node instanceof MappingNode) {
+ List keys = new ArrayList<>();
+ List values = new ArrayList<>();
+ for (NodeTuple tuple : ((MappingNode) node).getValue()) {
+ keys.add(keyText(tuple.getKeyNode()));
+ values.add(convert(tuple.getValueNode(), depth + 1));
+ }
+ return ScriptNode.ofMap(keys, values, line);
+ }
+ if (node instanceof SequenceNode) {
+ List items = new ArrayList<>();
+ for (Node item : ((SequenceNode) node).getValue()) {
+ items.add(convert(item, depth + 1));
+ }
+ return ScriptNode.ofList(items, line);
+ }
+ if (node instanceof ScalarNode) {
+ return scalar((ScalarNode) node, line);
+ }
+ return ScriptNode.ofNull(line);
+ }
+
+ /**
+ * A key is the text it is written as, as JavaScript makes every key of
+ * an object a string.
+ */
+ private static String keyText(Node key) {
+ return key instanceof ScalarNode ? ((ScalarNode) key).getValue() : "";
+ }
+
+ /**
+ * Resolve a scalar. A plain scalar takes the tag the YAML 1.2 core
+ * schema gives it, and any other scalar is a string.
+ */
+ static ScriptNode scalar(ScalarNode node, int line) {
+ String value = node.getValue();
+ if (node.isPlain() == false) {
+ return ScriptNode.ofString(value, line);
+ }
+ switch (value) {
+ case "":
+ case "~":
+ case "null":
+ case "Null":
+ case "NULL":
+ return ScriptNode.ofNull(line);
+ case "true":
+ case "True":
+ case "TRUE":
+ return ScriptNode.ofBool(true, line);
+ case "false":
+ case "False":
+ case "FALSE":
+ return ScriptNode.ofBool(false, line);
+ default:
+ break;
+ }
+ Double number = coreNumber(value);
+ return number == null ?
+ ScriptNode.ofString(value, line) :
+ ScriptNode.ofNumber(number, line);
+ }
+
+ /**
+ * The number a plain scalar is under the core schema, or null where it
+ * is not one.
+ */
+ private static Double coreNumber(String value) {
+ if (DECIMAL_INT.matcher(value).matches() ||
+ FLOAT.matcher(value).matches()) {
+ return Double.parseDouble(value);
+ }
+ if (OCTAL_INT.matcher(value).matches()) {
+ return new BigInteger(
+ value.substring(RADIX_PREFIX_LENGTH), OCTAL_RADIX)
+ .doubleValue();
+ }
+ if (HEX_INT.matcher(value).matches()) {
+ return new BigInteger(
+ value.substring(RADIX_PREFIX_LENGTH), HEX_RADIX)
+ .doubleValue();
+ }
+ if (INFINITY.matcher(value).matches()) {
+ return value.charAt(0) == '-' ?
+ Double.NEGATIVE_INFINITY : Double.POSITIVE_INFINITY;
+ }
+ if (NOT_A_NUMBER.matcher(value).matches()) {
+ return Double.NaN;
+ }
+ return null;
+ }
+
+ /**
+ * Refuse two keys of one mapping that differ only in case, because keys
+ * are matched without regard to case and one of the two would be
+ * dropped, and refuse a key written twice for the same reason.
+ */
+ private static void requireDistinctKeys(ScriptNode node)
+ throws ScriptParseException {
+ if (node.getKind() == ScriptNode.Kind.LIST) {
+ for (ScriptNode item : node.getItems()) {
+ requireDistinctKeys(item);
+ }
+ return;
+ }
+ if (node.getKind() != ScriptNode.Kind.MAP) {
+ return;
+ }
+ Map seen = new HashMap<>();
+ for (int i = 0; i < node.getKeys().size(); i++) {
+ String key = node.getKeys().get(i);
+ String first = seen.get(key.toLowerCase(Locale.ROOT));
+ if (first != null) {
+ if (first.equals(key)) {
+ throw new ScriptParseException(
+ "the key '" + key + "' is written twice in one " +
+ "mapping, so one of the two would be dropped",
+ node.getLine());
+ }
+ throw new ScriptParseException(
+ "the keys '" + first + "' and '" + key + "' differ only " +
+ "in case, and keys are matched without regard to " +
+ "case, so one of the two would be dropped",
+ node.getLine());
+ }
+ seen.put(key.toLowerCase(Locale.ROOT), key);
+ requireDistinctKeys(node.getValues().get(i));
+ }
+ }
+}
diff --git a/pipeline/pipeline.derivedproperty/src/main/java/fiftyone/pipeline/derivedproperty/flowelements/DerivedScriptValidator.java b/pipeline/pipeline.derivedproperty/src/main/java/fiftyone/pipeline/derivedproperty/flowelements/DerivedScriptValidator.java
new file mode 100644
index 000000000..a801ba562
--- /dev/null
+++ b/pipeline/pipeline.derivedproperty/src/main/java/fiftyone/pipeline/derivedproperty/flowelements/DerivedScriptValidator.java
@@ -0,0 +1,1168 @@
+/* *********************************************************************
+ * This Original Work is copyright of 51 Degrees Mobile Experts Limited.
+ * Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House,
+ * Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU.
+ *
+ * This Original Work is licensed under the European Union Public Licence
+ * (EUPL) v.1.2 and is subject to its terms as set out below.
+ *
+ * If a copy of the EUPL was not distributed with this file, You can obtain
+ * one at https://opensource.org/licenses/EUPL-1.2.
+ *
+ * The 'Compatible Licences' set out in the Appendix to the EUPL (as may be
+ * amended by the European Commission) shall be deemed incompatible for
+ * the purposes of the Work and the provisions of the compatibility
+ * clause in Article 5 of the EUPL shall not apply.
+ *
+ * If using the Work as, or as part of, a network application, by
+ * including the attribution notice(s) required under Article 5 of the EUPL
+ * in the end user terms of the application under an appropriate heading,
+ * such notice(s) shall fulfill the requirements of that article.
+ * ********************************************************************* */
+
+package fiftyone.pipeline.derivedproperty.flowelements;
+
+import fiftyone.pipeline.derivedproperty.DerivedScriptFault;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.regex.Pattern;
+
+/**
+ * Checks a parsed script and builds its model, collecting every fault
+ * rather than stopping at the first. The paths and the messages are those
+ * of the reference implementation, tools/validate.mjs in the
+ * derived-properties repository, so a fault reads the same in every
+ * language.
+ *
+ * See format
+ * 1 .
+ */
+final class DerivedScriptValidator {
+
+ /**
+ * The result of validating one script.
+ */
+ static final class Result {
+ private final DerivedScript script;
+ private final List faults;
+
+ Result(DerivedScript script, List faults) {
+ this.script = script;
+ this.faults = Collections.unmodifiableList(faults);
+ }
+
+ /**
+ * @return the model, or null where the script has a fault
+ */
+ DerivedScript getScript() {
+ return script;
+ }
+
+ List getFaults() {
+ return faults;
+ }
+ }
+
+ static final List VALUE_TYPES =
+ Arrays.asList("string", "bool", "int", "double");
+ static final List AGGREGATE_NAMES =
+ Arrays.asList("Passed", "Failed");
+ static final List TOP_LEVEL_KEYS = Arrays.asList(
+ "Format", "Name", "Version", "Deprecated", "DeprecationNote",
+ "Output", "Checks", "Rules");
+ /**
+ * The keys of Output, in the order the canonical form prints them.
+ */
+ static final List OUTPUT_KEYS = Arrays.asList(
+ "Name", "Description", "ValueType", "StoredValueType",
+ "DefaultValue", "IsList", "IsMandatory", "IsObsolete", "Category",
+ "IsPopular", "ExportValues", "Url", "DisplayOrder", "PropertyId",
+ "VendorIds", "Dependencies", "Values");
+ private static final List COUNT_OPERATORS =
+ Arrays.asList("Eq", "Ne", "Gt", "Ge", "Lt", "Le");
+
+ private static final Pattern IDENTIFIER =
+ Pattern.compile("^[A-Za-z][A-Za-z0-9]*$");
+ private static final Pattern SOURCE_PATTERN =
+ Pattern.compile("^[A-Za-z][A-Za-z0-9]*\\.[A-Za-z][A-Za-z0-9]*$");
+ private static final Pattern SEMANTIC_VERSION = Pattern.compile(
+ "^\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+(?:\\.[0-9A-Za-z.-]+)*)?" +
+ "(?:\\+[0-9A-Za-z.-]+)?$");
+
+ /**
+ * Where a source property was first seen and the type inferred there,
+ * so a conflict can name both places.
+ */
+ private static final class PropertyUse {
+ private final int slot;
+ private final ValueType type;
+ private final String typePath;
+
+ PropertyUse(int slot, ValueType type, String typePath) {
+ this.slot = slot;
+ this.type = type;
+ this.typePath = typePath;
+ }
+ }
+
+ private final ScriptNode document;
+ private final String script;
+ private final String source;
+ private final List faults = new ArrayList<>();
+ private final Map uses = new HashMap<>();
+ private final List properties = new ArrayList<>();
+
+ private DerivedScriptValidator(
+ ScriptNode document,
+ String script,
+ String source) {
+ this.document = document;
+ this.script = script;
+ this.source = source;
+ }
+
+ /**
+ * Parse and validate the text of a script.
+ * @param text the script, as YAML or JSON
+ * @param name the file name without its extension, which the script's
+ * Name must equal, or empty where there is none
+ * @param source the built-in script's name, the file path, or "code"
+ * @return the model, or every fault found
+ */
+ static Result validate(String text, String name, String source) {
+ String expected = name == null ? "" : name;
+ ScriptNode document;
+ try {
+ document = DerivedScriptParser.parse(text);
+ } catch (ScriptParseException e) {
+ List faults = new ArrayList<>();
+ faults.add(new DerivedScriptFault(
+ expected, source, "", e.getLine(), e.getMessage()));
+ return new Result(null, faults);
+ }
+ DerivedScriptValidator validator =
+ new DerivedScriptValidator(document, expected, source);
+ DerivedScript model = validator.buildModel();
+ if (validator.faults.isEmpty() == false) {
+ return new Result(null, validator.faults);
+ }
+ return new Result(model, validator.faults);
+ }
+
+ private void fault(String path, ScriptNode at, String message) {
+ int line = at != null && at.isCollection() ? at.getLine() : 0;
+ faults.add(new DerivedScriptFault(
+ script, source, path, line, message));
+ }
+
+ private DerivedScript buildModel() {
+ for (String key : unknownKeys(document, TOP_LEVEL_KEYS)) {
+ fault(key, document, "unknown key '" + key + "' at the top " +
+ "level. Expected one of " + String.join(", ", TOP_LEVEL_KEYS));
+ }
+ int format = readFormat();
+ String name = readName();
+ String version = readVersion();
+ boolean deprecated = readDeprecated();
+ String deprecationNote = readDeprecationNote(deprecated);
+ OutputModel.Draft output = readOutput();
+ // Checks are read before rules, so that a rule can refer to one.
+ List checkNames = readCheckNames();
+ List checks = readChecks(checkNames);
+ List rules = readRules(output, checkNames);
+ List computed = new ArrayList<>();
+ if (output.dependencies == null) {
+ for (SourceProperty property : properties) {
+ computed.add(property.getName());
+ }
+ }
+ return new DerivedScript(format, name, version, deprecated,
+ deprecationNote, source, new OutputModel(output, computed),
+ properties, checks, rules);
+ }
+
+ private int readFormat() {
+ ScriptNode value = document.get("Format");
+ if (value == null) {
+ fault("Format", document, "required key 'Format' is missing");
+ return 0;
+ }
+ if (value.getKind() != ScriptNode.Kind.NUMBER ||
+ value.getNumber() != 1) {
+ fault("Format", document,
+ "Format must be 1, found " + jsonOf(value));
+ return 0;
+ }
+ return 1;
+ }
+
+ private String readName() {
+ ScriptNode value = document.get("Name");
+ if (value == null) {
+ fault("Name", document, "required key 'Name' is missing");
+ return "";
+ }
+ if (value.getKind() != ScriptNode.Kind.STRING) {
+ fault("Name", document,
+ "Name expected a string, found " + typeOf(value));
+ return "";
+ }
+ if (IDENTIFIER.matcher(value.getText()).matches() == false) {
+ fault("Name", document, "script name '" + value.getText() +
+ "' does not match the pattern " + IDENTIFIER.pattern());
+ return value.getText();
+ }
+ if (script.isEmpty() == false &&
+ script.equals(value.getText()) == false) {
+ fault("Name", document, "script name '" + value.getText() +
+ "' must equal the file name '" + script + "'");
+ }
+ return value.getText();
+ }
+
+ private String readVersion() {
+ ScriptNode value = document.get("Version");
+ if (value == null) {
+ fault("Version", document, "required key 'Version' is missing");
+ return "";
+ }
+ if (value.getKind() != ScriptNode.Kind.STRING ||
+ SEMANTIC_VERSION.matcher(value.getText()).matches() == false) {
+ fault("Version", document, "Version expected a semantic " +
+ "version such as 1.0.0, found " + jsonOf(value));
+ return "";
+ }
+ return value.getText();
+ }
+
+ private boolean readDeprecated() {
+ ScriptNode deprecated = document.get("Deprecated");
+ if (deprecated == null) {
+ return false;
+ }
+ if (deprecated.getKind() != ScriptNode.Kind.BOOL) {
+ fault("Deprecated", document,
+ "Deprecated expected a boolean, found " + typeOf(deprecated));
+ return false;
+ }
+ return deprecated.getBool();
+ }
+
+ private String readDeprecationNote(boolean deprecated) {
+ ScriptNode note = document.get("DeprecationNote");
+ String text = "";
+ if (note != null && note.getKind() != ScriptNode.Kind.STRING) {
+ fault("DeprecationNote", document,
+ "DeprecationNote expected a string, found " + typeOf(note));
+ } else if (note != null) {
+ text = note.getText();
+ }
+ if (deprecated && text.isEmpty()) {
+ fault("DeprecationNote", document, "a deprecated script must " +
+ "say what to use instead in DeprecationNote");
+ }
+ if (deprecated == false && text.isEmpty() == false) {
+ fault("DeprecationNote", document,
+ "DeprecationNote is only allowed when Deprecated is true");
+ }
+ return text;
+ }
+
+ private OutputModel.Draft readOutput() {
+ OutputModel.Draft output = new OutputModel.Draft();
+ String fallbackName = script.isEmpty() ? "unknown" : script;
+ ScriptNode raw = document.get("Output");
+ if (raw == null) {
+ fault("Output", document, "required key 'Output' is missing");
+ output.name = fallbackName;
+ return output;
+ }
+ if (raw.getKind() != ScriptNode.Kind.MAP) {
+ fault("Output", document,
+ "Output expected a mapping, found " + typeOf(raw));
+ output.name = fallbackName;
+ return output;
+ }
+ for (String key : unknownKeys(raw, OUTPUT_KEYS)) {
+ fault("Output." + key, raw, "unknown key '" + key + "' under " +
+ "Output. A typo in a metadata field is a fault rather than " +
+ "a value that is quietly dropped");
+ }
+
+ ScriptNode name = raw.get("Name");
+ if (name == null) {
+ fault("Output.Name", raw, "required key 'Name' is missing");
+ } else if (name.getKind() != ScriptNode.Kind.STRING ||
+ IDENTIFIER.matcher(name.getText()).matches() == false) {
+ fault("Output.Name", raw, "Output.Name " + jsonOf(name) +
+ " does not match the pattern " + IDENTIFIER.pattern());
+ } else {
+ output.name = name.getText();
+ }
+ if (output.name.isEmpty()) {
+ output.name = fallbackName;
+ }
+
+ ScriptNode description = raw.get("Description");
+ if (description == null) {
+ fault("Output.Description", raw, "required key 'Description' " +
+ "is missing. Say what the property asserts, not how far to " +
+ "trust it");
+ } else if (description.getKind() != ScriptNode.Kind.STRING ||
+ JsText.trim(description.getText()).isEmpty()) {
+ fault("Output.Description", raw, "Output.Description expected " +
+ "a non empty string, found " + typeOf(description));
+ } else {
+ output.description = description.getText();
+ }
+
+ ScriptNode valueType = raw.get("ValueType");
+ if (valueType == null) {
+ fault("Output.ValueType", raw,
+ "required key 'ValueType' is missing");
+ } else if (valueType.getKind() != ScriptNode.Kind.STRING ||
+ ValueType.fromText(valueType.getText()) == null) {
+ fault("Output.ValueType", raw, "Output.ValueType '" +
+ displayOf(valueType) + "' is not allowed in format 1. " +
+ "Expected one of " + String.join(", ", VALUE_TYPES));
+ } else {
+ output.valueType = ValueType.fromText(valueType.getText());
+ }
+
+ ScriptNode isList = raw.get("IsList");
+ if (isList == null) {
+ fault("Output.IsList", raw, "required key 'IsList' is missing");
+ } else if (isList.getKind() != ScriptNode.Kind.BOOL) {
+ fault("Output.IsList", raw,
+ "Output.IsList expected a boolean, found " + typeOf(isList));
+ } else if (isList.getBool()) {
+ fault("Output.IsList", raw, "Output.IsList must be false in " +
+ "format 1. List outputs are deferred");
+ }
+
+ readValues(raw, output);
+
+ output.isMandatory = readBoolean(raw, "IsMandatory");
+ output.isObsolete = readBoolean(raw, "IsObsolete");
+ output.isPopular = readBoolean(raw, "IsPopular");
+ output.exportValues = readBoolean(raw, "ExportValues");
+ output.category = readString(raw, "Category");
+ output.url = readString(raw, "Url");
+ output.storedValueType = readString(raw, "StoredValueType");
+ output.displayOrder = readInteger(raw, "DisplayOrder");
+ output.propertyId = readInteger(raw, "PropertyId");
+ output.vendorIds = readList(raw, "VendorIds");
+ output.dependencies = readList(raw, "Dependencies");
+
+ // DefaultValue is the string form of the value, so it is checked
+ // against the value type and the value list.
+ ScriptNode defaultValue = raw.get("DefaultValue");
+ if (defaultValue != null) {
+ if (defaultValue.getKind() != ScriptNode.Kind.STRING) {
+ fault("Output.DefaultValue", raw, "Output.DefaultValue " +
+ "expected a string holding the string form of the " +
+ "value, found " + typeOf(defaultValue));
+ } else {
+ String text = defaultValue.getText();
+ output.defaultValue = text;
+ if (output.valueType != null &&
+ SourceValues.convertString(text, output.valueType) ==
+ null) {
+ fault("Output.DefaultValue", raw, "Output.DefaultValue '" +
+ text + "' cannot be read as " + output.valueType);
+ }
+ if (output.hasValues && output.listsValue(text) == false) {
+ fault("Output.DefaultValue", raw, "Output.DefaultValue '" +
+ text + "' is not one of the values listed under " +
+ "Output.Values");
+ }
+ }
+ }
+ return output;
+ }
+
+ private Boolean readBoolean(ScriptNode raw, String key) {
+ ScriptNode value = raw.get(key);
+ if (value == null) {
+ return null;
+ }
+ if (value.getKind() != ScriptNode.Kind.BOOL) {
+ fault("Output." + key, raw, "Output." + key +
+ " expected a boolean, found " + typeOf(value));
+ return null;
+ }
+ return value.getBool();
+ }
+
+ private String readString(ScriptNode raw, String key) {
+ ScriptNode value = raw.get(key);
+ if (value == null) {
+ return null;
+ }
+ if (value.getKind() != ScriptNode.Kind.STRING) {
+ fault("Output." + key, raw, "Output." + key +
+ " expected a string, found " + typeOf(value));
+ return null;
+ }
+ return value.getText();
+ }
+
+ private Long readInteger(ScriptNode raw, String key) {
+ ScriptNode value = raw.get(key);
+ if (value == null) {
+ return null;
+ }
+ if (value.getKind() != ScriptNode.Kind.NUMBER ||
+ isWhole(value.getNumber()) == false) {
+ fault("Output." + key, raw, "Output." + key +
+ " expected an integer, found " + typeOf(value));
+ return null;
+ }
+ return (long) value.getNumber();
+ }
+
+ private ScriptNode readList(ScriptNode raw, String key) {
+ ScriptNode value = raw.get(key);
+ if (value == null) {
+ return null;
+ }
+ if (value.getKind() != ScriptNode.Kind.LIST) {
+ fault("Output." + key, raw, "Output." + key +
+ " expected a list, found " + typeOf(value));
+ return null;
+ }
+ return value;
+ }
+
+ private void readValues(ScriptNode raw, OutputModel.Draft output) {
+ ScriptNode values = raw.get("Values");
+ if (values == null) {
+ return;
+ }
+ if (values.getKind() != ScriptNode.Kind.LIST) {
+ fault("Output.Values", raw,
+ "Output.Values expected a list, found " + typeOf(values));
+ return;
+ }
+ if (output.valueType != null &&
+ output.valueType != ValueType.STRING &&
+ output.valueType != ValueType.INT) {
+ fault("Output.Values", raw, "Output.Values is only allowed " +
+ "where ValueType is string or int, not " + output.valueType);
+ return;
+ }
+ for (int index = 0; index < values.getItems().size(); index++) {
+ ScriptNode entry = values.getItems().get(index);
+ String path = "Output.Values[" + index + "]";
+ if (entry.getKind() != ScriptNode.Kind.MAP) {
+ fault(path, values, "a value expected a mapping of Name " +
+ "and Description, found " + typeOf(entry));
+ continue;
+ }
+ for (String key : unknownKeys(
+ entry, Arrays.asList("Name", "Description"))) {
+ fault(path + "." + key, entry, "unknown key '" + key +
+ "' in a value. Expected Name and Description");
+ }
+ ScriptNode name = entry.get("Name");
+ if (name == null || (name.getKind() != ScriptNode.Kind.STRING &&
+ name.getKind() != ScriptNode.Kind.NUMBER)) {
+ fault(path + ".Name", entry, "a value must have a Name");
+ continue;
+ }
+ String text = null;
+ ScriptNode description = entry.get("Description");
+ if (description != null) {
+ if (description.getKind() != ScriptNode.Kind.STRING) {
+ fault(path + ".Description", entry, "a value " +
+ "Description expected a string, found " +
+ typeOf(description));
+ }
+ text = description.getText();
+ }
+ output.values.add(
+ new OutputModel.ValueEntry(Literal.of(name), text));
+ }
+ List seen = new ArrayList<>();
+ for (OutputModel.ValueEntry entry : output.values) {
+ String key = entry.getName().toString();
+ if (seen.contains(key)) {
+ fault("Output.Values", values,
+ "the value '" + key + "' is listed more than once");
+ }
+ seen.add(key);
+ }
+ output.hasValues = true;
+ }
+
+ private List readCheckNames() {
+ ScriptNode raw = document.get("Checks");
+ if (raw == null) {
+ return Collections.emptyList();
+ }
+ if (raw.getKind() != ScriptNode.Kind.MAP) {
+ fault("Checks", document, "Checks expected a mapping of names " +
+ "to conditions, found " + typeOf(raw));
+ return Collections.emptyList();
+ }
+ List names = new ArrayList<>();
+ for (String name : raw.getKeys()) {
+ if (IDENTIFIER.matcher(name).matches() == false) {
+ fault("Checks." + name, raw, "check name '" + name +
+ "' does not match the pattern " + IDENTIFIER.pattern());
+ continue;
+ }
+ names.add(name);
+ }
+ return names;
+ }
+
+ private List readChecks(
+ List checkNames) {
+ List checks = new ArrayList<>();
+ ScriptNode raw = document.get("Checks");
+ if (raw == null || raw.getKind() != ScriptNode.Kind.MAP) {
+ return checks;
+ }
+ for (String name : checkNames) {
+ Condition condition = readCondition(
+ raw.valueOf(name), "Checks." + name, raw, checkNames);
+ checks.add(new DerivedScript.NamedCheck(name, condition));
+ }
+ return checks;
+ }
+
+ private List readRules(
+ OutputModel.Draft output,
+ List checkNames) {
+ List rules = new ArrayList<>();
+ ScriptNode raw = document.get("Rules");
+ if (raw == null) {
+ fault("Rules", document, "required key 'Rules' is missing");
+ return rules;
+ }
+ if (raw.getKind() != ScriptNode.Kind.LIST) {
+ fault("Rules", document,
+ "Rules expected a list, found " + typeOf(raw));
+ return rules;
+ }
+ List items = raw.getItems();
+ if (items.isEmpty()) {
+ fault("Rules", raw, "Rules must hold at least one rule");
+ return rules;
+ }
+ // Every script ends in an Else, so a script always chooses a value
+ // once its source properties have been read.
+ if (items.get(items.size() - 1).get("Else") == null) {
+ fault("Rules", raw, "the last rule must be an Else, which is " +
+ "what a script falls back to when no earlier rule matched");
+ }
+ for (int index = 0; index < items.size(); index++) {
+ ScriptNode entry = items.get(index);
+ String path = "Rules[" + index + "]";
+ if (entry.getKind() != ScriptNode.Kind.MAP) {
+ fault(path, raw, "a rule expected a mapping, found " +
+ typeOf(entry));
+ continue;
+ }
+ for (String key : unknownKeys(
+ entry, Arrays.asList("When", "Then", "Else"))) {
+ fault(path + "." + key, entry, "unknown key '" + key +
+ "' in a rule. Expected When and Then, or Else");
+ }
+ ScriptNode when = entry.get("When");
+ ScriptNode then = entry.get("Then");
+ ScriptNode otherwise = entry.get("Else");
+ boolean isLast = index == items.size() - 1;
+ if (otherwise != null && when != null) {
+ fault(path, entry, "a rule has both When and Else. A rule " +
+ "is either When with Then, or Else on its own");
+ continue;
+ }
+ if (otherwise != null && isLast == false) {
+ fault(path, entry, "Else is only allowed on the last rule");
+ continue;
+ }
+ if (otherwise == null && when == null) {
+ fault(path, entry,
+ "a rule needs a When, or an Else on the last rule");
+ continue;
+ }
+ if (when != null && then == null) {
+ fault(path, entry, "a rule with When needs a Then");
+ continue;
+ }
+ Literal value = otherwise != null ?
+ readRuleValue(output, otherwise, path + ".Else", entry) :
+ readRuleValue(output, then, path + ".Then", entry);
+ Condition condition = when == null ? null :
+ readCondition(when, path + ".When", entry, checkNames);
+ rules.add(new DerivedScript.Rule(condition, value));
+ }
+ return rules;
+ }
+
+ /**
+ * Read a Then or an Else, which is a literal of Output.ValueType and
+ * one of Output.Values where that list is given.
+ */
+ private Literal readRuleValue(
+ OutputModel.Draft output,
+ ScriptNode raw,
+ String path,
+ ScriptNode at) {
+ if (raw.getKind() == ScriptNode.Kind.NULL) {
+ fault(path, at, "a rule value is a null literal, which format 1 " +
+ "does not allow");
+ return Literal.NONE;
+ }
+ if (raw.isCollection()) {
+ fault(path, at, "a rule value is a literal of the output value " +
+ "type, found " + typeOf(raw));
+ return Literal.NONE;
+ }
+ if (output.valueType != null &&
+ literalMatches(inferType(raw), output.valueType) == false) {
+ fault(path, at, "expected a " + output.valueType +
+ " to match Output.ValueType, found " + typeOf(raw));
+ return Literal.NONE;
+ }
+ Literal value = Literal.of(raw);
+ if (output.hasValues && output.listsValue(value.toString()) == false) {
+ List names = new ArrayList<>();
+ for (OutputModel.ValueEntry entry : output.values) {
+ names.add(entry.getName().toString());
+ }
+ fault(path, at, "'" + value + "' is not one of the values " +
+ "listed under Output.Values (" + String.join(", ", names) +
+ ")");
+ return Literal.NONE;
+ }
+ return value;
+ }
+
+ private Condition readCondition(
+ ScriptNode raw,
+ String path,
+ ScriptNode parent,
+ List checkNames) {
+ if (raw == null || raw.getKind() != ScriptNode.Kind.MAP) {
+ fault(path, parent,
+ "a condition expected a mapping, found " + typeOf(raw));
+ return Condition.faulted();
+ }
+ if (raw.getKeys().isEmpty()) {
+ fault(path, raw, "a condition is empty");
+ return Condition.faulted();
+ }
+ if (raw.get("Property") != null) {
+ return readComparison(raw, path);
+ }
+ if (raw.get("Check") != null) {
+ return readCheckReference(raw, path, checkNames);
+ }
+ if (raw.get("Passed") != null || raw.get("Failed") != null) {
+ return readAggregate(raw, path, checkNames);
+ }
+ if (raw.get("All") != null || raw.get("Any") != null) {
+ String which = raw.get("All") != null ? "All" : "Any";
+ if (raw.getKeys().size() != 1) {
+ fault(path, raw, which + " must be the only key of its " +
+ "condition, found " + String.join(", ", raw.getKeys()));
+ }
+ ScriptNode items = raw.get(which);
+ if (items.getKind() != ScriptNode.Kind.LIST) {
+ fault(path + "." + which, raw, which + " expected a list of " +
+ "conditions, found " + typeOf(items));
+ return Condition.faulted();
+ }
+ if (items.getItems().isEmpty()) {
+ fault(path + "." + which, raw,
+ which + " must list at least one condition");
+ return Condition.faulted();
+ }
+ Condition[] members = new Condition[items.getItems().size()];
+ for (int index = 0; index < members.length; index++) {
+ members[index] = readCondition(
+ items.getItems().get(index),
+ path + "." + which + "[" + index + "]",
+ items,
+ checkNames);
+ }
+ return Condition.compound(
+ which.equals("All") ?
+ Condition.Kind.ALL : Condition.Kind.ANY,
+ members);
+ }
+ if (raw.get("Not") != null) {
+ if (raw.getKeys().size() != 1) {
+ fault(path, raw, "Not must be the only key of its " +
+ "condition, found " + String.join(", ", raw.getKeys()));
+ }
+ Condition item = readCondition(
+ raw.get("Not"), path + ".Not", raw, checkNames);
+ return Condition.compound(
+ Condition.Kind.NOT, new Condition[]{item});
+ }
+ fault(path, raw, "a condition must be a comparison, a Check " +
+ "reference, an aggregate, All, Any or Not. Found the keys " +
+ String.join(", ", raw.getKeys()));
+ return Condition.faulted();
+ }
+
+ private Condition readComparison(ScriptNode raw, String path) {
+ String propertyKey = null;
+ List operatorKeys = new ArrayList<>();
+ for (String key : raw.getKeys()) {
+ if (propertyKey == null && key.equalsIgnoreCase("Property")) {
+ propertyKey = key;
+ continue;
+ }
+ operatorKeys.add(key);
+ }
+ ScriptNode property = raw.valueOf(propertyKey);
+ if (property.getKind() != ScriptNode.Kind.STRING ||
+ SOURCE_PATTERN.matcher(property.getText()).matches() == false) {
+ fault(path + ".Property", raw, "'" + displayOf(property) +
+ "' is not a source property. Write it as " +
+ "elementKey.PropertyName, for example device.IsCrawler");
+ return Condition.faulted();
+ }
+ if (operatorKeys.isEmpty()) {
+ fault(path, raw, "a comparison on '" + property.getText() +
+ "' has no operator. Expected exactly one of " +
+ Operator.names());
+ return Condition.faulted();
+ }
+ List known = new ArrayList<>();
+ for (String key : operatorKeys) {
+ if (Operator.fromText(key) != null) {
+ known.add(key);
+ } else {
+ fault(path, raw, "unknown operator '" + key +
+ "', expected one of " + Operator.names());
+ }
+ }
+ if (known.isEmpty()) {
+ return Condition.faulted();
+ }
+ if (operatorKeys.size() > 1) {
+ fault(path, raw, "a condition takes exactly one operator, found " +
+ String.join(", ", operatorKeys));
+ return Condition.faulted();
+ }
+ Operator operator = Operator.fromText(known.get(0));
+ ScriptNode operand = raw.valueOf(known.get(0));
+ if (operand.getKind() == ScriptNode.Kind.NULL) {
+ fault(path + "." + operator, raw, "a null literal is not " +
+ "allowed. Give the value to compare against");
+ return Condition.faulted();
+ }
+
+ ValueType type;
+ if (operator.takesList()) {
+ if (operand.getKind() != ScriptNode.Kind.LIST) {
+ fault(path + "." + operator, raw, operator + " expects a " +
+ "list of values, found " + typeOf(operand));
+ return Condition.faulted();
+ }
+ if (operand.getItems().isEmpty()) {
+ fault(path + "." + operator, raw,
+ operator + " expects a non empty list");
+ return Condition.faulted();
+ }
+ // A member with no type, such as a mapping, joins as an empty
+ // string, as JavaScript joins a null.
+ List memberTypes = new ArrayList<>();
+ boolean numeric = true;
+ boolean allSame = true;
+ for (ScriptNode member : operand.getItems()) {
+ if (member.getKind() == ScriptNode.Kind.NULL) {
+ fault(path + "." + operator, raw,
+ "a null literal is not allowed in a list");
+ return Condition.faulted();
+ }
+ ValueType memberType = inferType(member);
+ String text = memberType == null ? "" : memberType.toString();
+ memberTypes.add(text);
+ if (memberType != ValueType.INT &&
+ memberType != ValueType.DOUBLE) {
+ numeric = false;
+ }
+ if (text.equals(memberTypes.get(0)) == false) {
+ allSame = false;
+ }
+ }
+ if (numeric == false && allSame == false) {
+ fault(path + "." + operator, raw, "every member of a list " +
+ "must be of the same type, found " +
+ String.join(", ", memberTypes));
+ return Condition.faulted();
+ }
+ if (numeric) {
+ type = memberTypes.contains(ValueType.DOUBLE.toString()) ?
+ ValueType.DOUBLE : ValueType.INT;
+ } else {
+ type = inferType(operand.getItems().get(0));
+ }
+ } else {
+ type = inferType(operand);
+ if (type == null) {
+ fault(path + "." + operator, raw, "the literal " +
+ jsonOf(operand) + " has no type format 1 knows");
+ return Condition.faulted();
+ }
+ }
+
+ if (type == null || operator.getTypes().contains(type) == false) {
+ List allowed = new ArrayList<>();
+ for (ValueType t : operator.getTypes()) {
+ allowed.add(t.toString());
+ }
+ fault(path, raw, "operator '" + operator + "' is not allowed on " +
+ "type " + (type == null ? "null" : type.toString()) +
+ ". It is allowed on " + String.join(", ", allowed));
+ return Condition.faulted();
+ }
+
+ int slot = useProperty(property.getText(), type, path, raw);
+ return Condition.comparison(
+ slot, property.getText(), operator, operand, type);
+ }
+
+ private Condition readCheckReference(
+ ScriptNode raw,
+ String path,
+ List checkNames) {
+ if (raw.getKeys().size() != 1) {
+ fault(path, raw, "Check must be the only key of its condition, " +
+ "found " + String.join(", ", raw.getKeys()));
+ }
+ ScriptNode name = raw.get("Check");
+ if (name.getKind() != ScriptNode.Kind.STRING) {
+ fault(path + ".Check", raw,
+ "Check expected the name of a check, found " + typeOf(name));
+ return Condition.faulted();
+ }
+ int index = checkNames.indexOf(name.getText());
+ if (index < 0) {
+ fault(path + ".Check", raw, "check '" + name.getText() +
+ "' is not defined. The checks are " + listOrNone(checkNames));
+ return Condition.faulted();
+ }
+ return Condition.checkReference(name.getText(), index);
+ }
+
+ private Condition readAggregate(
+ ScriptNode raw,
+ String path,
+ List checkNames) {
+ List aggregateKeys = new ArrayList<>();
+ for (String key : raw.getKeys()) {
+ if (canonicalName(AGGREGATE_NAMES, key) != null) {
+ aggregateKeys.add(key);
+ }
+ }
+ if (aggregateKeys.size() > 1) {
+ fault(path, raw, "an aggregate condition takes one of " +
+ String.join(", ", AGGREGATE_NAMES) + ", found " +
+ String.join(", ", aggregateKeys));
+ return Condition.faulted();
+ }
+ String aggregateKey = aggregateKeys.get(0);
+ String aggregate = canonicalName(AGGREGATE_NAMES, aggregateKey);
+ List groupNames = new ArrayList<>();
+ int[] group = readGroup(raw.valueOf(aggregateKey),
+ path + "." + aggregate, raw, checkNames, groupNames);
+
+ List operatorKeys = new ArrayList<>();
+ for (String key : raw.getKeys()) {
+ if (key.equals(aggregateKey) == false) {
+ operatorKeys.add(key);
+ }
+ }
+ if (operatorKeys.isEmpty()) {
+ fault(path, raw, "an aggregate condition has no operator. " +
+ "Expected exactly one of " +
+ String.join(", ", COUNT_OPERATORS));
+ return Condition.faulted();
+ }
+ if (operatorKeys.size() > 1) {
+ fault(path, raw, "a condition takes exactly one operator, found " +
+ String.join(", ", operatorKeys));
+ return Condition.faulted();
+ }
+ Operator operator = Operator.fromText(operatorKeys.get(0));
+ if (operator == null) {
+ fault(path, raw, "unknown operator '" + operatorKeys.get(0) +
+ "', expected one of " + String.join(", ", COUNT_OPERATORS));
+ return Condition.faulted();
+ }
+ // In and NotIn are refused as well, because a count is compared
+ // with one whole number, never with a list.
+ if (COUNT_OPERATORS.contains(operator.toString()) == false) {
+ fault(path, raw, "operator '" + operator + "' is not allowed " +
+ "on a count, which is an int");
+ return Condition.faulted();
+ }
+ // A count is compared with a whole number and with nothing else.
+ ScriptNode operand = raw.valueOf(operatorKeys.get(0));
+ if (operand.getKind() != ScriptNode.Kind.NUMBER ||
+ isWhole(operand.getNumber()) == false) {
+ fault(path + "." + operator, raw, "an aggregate is compared " +
+ "with a whole number, found " + typeOf(operand));
+ return Condition.faulted();
+ }
+ return Condition.aggregate(
+ aggregate.equals("Passed"),
+ group,
+ group == null ? null : groupNames,
+ operator,
+ operand);
+ }
+
+ /**
+ * Read the word Checks, meaning every check, which gives a null group,
+ * or a list of check names, which gives their indexes.
+ */
+ private int[] readGroup(
+ ScriptNode raw,
+ String path,
+ ScriptNode at,
+ List checkNames,
+ List groupNames) {
+ if (raw.getKind() == ScriptNode.Kind.STRING) {
+ if (raw.getText().equalsIgnoreCase("Checks")) {
+ return null;
+ }
+ fault(path, at, "a group is the word Checks, meaning every " +
+ "check, or a list of check names. Found '" + raw.getText() +
+ "'");
+ return new int[0];
+ }
+ if (raw.getKind() == ScriptNode.Kind.LIST) {
+ List indexes = new ArrayList<>();
+ for (ScriptNode name : raw.getItems()) {
+ if (name.getKind() != ScriptNode.Kind.STRING) {
+ fault(path, at, "a group lists check names, found " +
+ typeOf(name));
+ continue;
+ }
+ int index = checkNames.indexOf(name.getText());
+ if (index < 0) {
+ fault(path, at, "check '" + name.getText() + "' is not " +
+ "defined. The checks are " + listOrNone(checkNames));
+ continue;
+ }
+ indexes.add(index);
+ groupNames.add(name.getText());
+ }
+ int[] group = new int[indexes.size()];
+ for (int i = 0; i < group.length; i++) {
+ group[i] = indexes.get(i);
+ }
+ return group;
+ }
+ fault(path, at, "a group is the word Checks or a list of check " +
+ "names, found " + typeOf(raw));
+ return new int[0];
+ }
+
+ /**
+ * Record that a condition names a source property with the type its
+ * literal infers, and give the slot the property is read from.
+ */
+ private int useProperty(
+ String name,
+ ValueType type,
+ String path,
+ ScriptNode at) {
+ String key = name.toLowerCase(Locale.ROOT);
+ PropertyUse use = uses.get(key);
+ if (use == null) {
+ use = new PropertyUse(properties.size(), type, path);
+ uses.put(key, use);
+ properties.add(new SourceProperty(name, type));
+ return use.slot;
+ }
+ if (use.type != type) {
+ fault(path, at, "'" + name + "' is inferred as " + type +
+ " here but was already inferred as " + use.type + " at " +
+ use.typePath + ". Every use of a property must infer the " +
+ "same type");
+ }
+ return use.slot;
+ }
+
+ /**
+ * The type a literal infers. A number infers its type from its value
+ * rather than the way it is written, so 8, 8.0 and 8e0 all infer int,
+ * and a whole number outside the range of int infers double.
+ * @return the type, or null for a literal with no type
+ */
+ static ValueType inferType(ScriptNode node) {
+ switch (node.getKind()) {
+ case BOOL:
+ return ValueType.BOOL;
+ case NUMBER:
+ if (isWhole(node.getNumber()) == false) {
+ return ValueType.DOUBLE;
+ }
+ return node.getNumber() >= ValueType.INT_MIN &&
+ node.getNumber() <= ValueType.INT_MAX ?
+ ValueType.INT : ValueType.DOUBLE;
+ case STRING:
+ return ValueType.STRING;
+ default:
+ return null;
+ }
+ }
+
+ /**
+ * Whether a literal of one type may stand for a value of another. A
+ * whole number reads as a double.
+ */
+ private static boolean literalMatches(
+ ValueType literalType,
+ ValueType outputType) {
+ return literalType == outputType ||
+ (literalType == ValueType.INT && outputType == ValueType.DOUBLE);
+ }
+
+ static boolean isWhole(double value) {
+ return Double.isInfinite(value) == false &&
+ Double.isNaN(value) == false &&
+ value == Math.floor(value);
+ }
+
+ /**
+ * Describe a value in a message.
+ */
+ static String typeOf(ScriptNode node) {
+ if (node == null) {
+ return "nothing";
+ }
+ switch (node.getKind()) {
+ case NULL:
+ return "a null literal";
+ case LIST:
+ return "a list";
+ case BOOL:
+ return "a boolean";
+ case NUMBER:
+ return isWhole(node.getNumber()) ? "an integer" : "a number";
+ case STRING:
+ return "a string (" + jsonOf(node) + ")";
+ default:
+ return "a mapping";
+ }
+ }
+
+ /**
+ * A value as JavaScript's template strings print it.
+ */
+ static String displayOf(ScriptNode node) {
+ switch (node.getKind()) {
+ case NULL:
+ return "null";
+ case BOOL:
+ case NUMBER:
+ return Literal.of(node).toString();
+ case STRING:
+ return node.getText();
+ case LIST:
+ List parts = new ArrayList<>();
+ for (ScriptNode item : node.getItems()) {
+ parts.add(displayOf(item));
+ }
+ return String.join(",", parts);
+ default:
+ return "[object Object]";
+ }
+ }
+
+ /**
+ * A value as compact JSON, as JSON.stringify gives it.
+ */
+ static String jsonOf(ScriptNode node) {
+ StringBuilder text = new StringBuilder();
+ appendJson(text, node);
+ return text.toString();
+ }
+
+ private static void appendJson(StringBuilder text, ScriptNode node) {
+ switch (node.getKind()) {
+ case NULL:
+ text.append("null");
+ break;
+ case BOOL:
+ text.append(node.getBool() ? "true" : "false");
+ break;
+ case NUMBER:
+ text.append(Double.isInfinite(node.getNumber()) ||
+ Double.isNaN(node.getNumber()) ?
+ "null" : JsText.number(node.getNumber()));
+ break;
+ case STRING:
+ text.append(JsText.quote(node.getText()));
+ break;
+ case LIST:
+ text.append('[');
+ for (int i = 0; i < node.getItems().size(); i++) {
+ if (i > 0) {
+ text.append(',');
+ }
+ appendJson(text, node.getItems().get(i));
+ }
+ text.append(']');
+ break;
+ default:
+ text.append('{');
+ for (int i = 0; i < node.getKeys().size(); i++) {
+ if (i > 0) {
+ text.append(',');
+ }
+ text.append(JsText.quote(node.getKeys().get(i)))
+ .append(':');
+ appendJson(text, node.getValues().get(i));
+ }
+ text.append('}');
+ break;
+ }
+ }
+
+ private static List unknownKeys(
+ ScriptNode node,
+ List allowed) {
+ List unknown = new ArrayList<>();
+ for (String key : node.getKeys()) {
+ if (canonicalName(allowed, key) == null) {
+ unknown.add(key);
+ }
+ }
+ return unknown;
+ }
+
+ /**
+ * The name in names that matches the key without regard to case.
+ * @return the name, or null where none matches
+ */
+ private static String canonicalName(List names, String key) {
+ for (String name : names) {
+ if (name.equalsIgnoreCase(key)) {
+ return name;
+ }
+ }
+ return null;
+ }
+
+ private static String listOrNone(List names) {
+ return names.isEmpty() ? "(none)" : String.join(", ", names);
+ }
+}
diff --git a/pipeline/pipeline.derivedproperty/src/main/java/fiftyone/pipeline/derivedproperty/flowelements/DerivedValueMetaData.java b/pipeline/pipeline.derivedproperty/src/main/java/fiftyone/pipeline/derivedproperty/flowelements/DerivedValueMetaData.java
new file mode 100644
index 000000000..509daa525
--- /dev/null
+++ b/pipeline/pipeline.derivedproperty/src/main/java/fiftyone/pipeline/derivedproperty/flowelements/DerivedValueMetaData.java
@@ -0,0 +1,52 @@
+/* *********************************************************************
+ * This Original Work is copyright of 51 Degrees Mobile Experts Limited.
+ * Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House,
+ * Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU.
+ *
+ * This Original Work is licensed under the European Union Public Licence
+ * (EUPL) v.1.2 and is subject to its terms as set out below.
+ *
+ * If a copy of the EUPL was not distributed with this file, You can obtain
+ * one at https://opensource.org/licenses/EUPL-1.2.
+ *
+ * The 'Compatible Licences' set out in the Appendix to the EUPL (as may be
+ * amended by the European Commission) shall be deemed incompatible for
+ * the purposes of the Work and the provisions of the compatibility
+ * clause in Article 5 of the EUPL shall not apply.
+ *
+ * If using the Work as, or as part of, a network application, by
+ * including the attribution notice(s) required under Article 5 of the EUPL
+ * in the end user terms of the application under an appropriate heading,
+ * such notice(s) shall fulfill the requirements of that article.
+ * ********************************************************************* */
+
+package fiftyone.pipeline.derivedproperty.flowelements;
+
+/**
+ * One entry of a script's Output.Values, being a value the derived property
+ * can have.
+ */
+public final class DerivedValueMetaData {
+
+ private final String name;
+ private final String description;
+
+ DerivedValueMetaData(String name, String description) {
+ this.name = name;
+ this.description = description;
+ }
+
+ /**
+ * @return the string form of the value, as a rule's value is compared
+ */
+ public String getName() {
+ return name;
+ }
+
+ /**
+ * @return the description, or null where the script gives none
+ */
+ public String getDescription() {
+ return description;
+ }
+}
diff --git a/pipeline/pipeline.derivedproperty/src/main/java/fiftyone/pipeline/derivedproperty/flowelements/IDerivedPropertyElement.java b/pipeline/pipeline.derivedproperty/src/main/java/fiftyone/pipeline/derivedproperty/flowelements/IDerivedPropertyElement.java
new file mode 100644
index 000000000..fbbaff234
--- /dev/null
+++ b/pipeline/pipeline.derivedproperty/src/main/java/fiftyone/pipeline/derivedproperty/flowelements/IDerivedPropertyElement.java
@@ -0,0 +1,45 @@
+/* *********************************************************************
+ * This Original Work is copyright of 51 Degrees Mobile Experts Limited.
+ * Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House,
+ * Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU.
+ *
+ * This Original Work is licensed under the European Union Public Licence
+ * (EUPL) v.1.2 and is subject to its terms as set out below.
+ *
+ * If a copy of the EUPL was not distributed with this file, You can obtain
+ * one at https://opensource.org/licenses/EUPL-1.2.
+ *
+ * The 'Compatible Licences' set out in the Appendix to the EUPL (as may be
+ * amended by the European Commission) shall be deemed incompatible for
+ * the purposes of the Work and the provisions of the compatibility
+ * clause in Article 5 of the EUPL shall not apply.
+ *
+ * If using the Work as, or as part of, a network application, by
+ * including the attribution notice(s) required under Article 5 of the EUPL
+ * in the end user terms of the application under an appropriate heading,
+ * such notice(s) shall fulfill the requirements of that article.
+ * ********************************************************************* */
+
+package fiftyone.pipeline.derivedproperty.flowelements;
+
+import fiftyone.pipeline.core.data.ElementPropertyMetaData;
+import fiftyone.pipeline.core.flowelements.FlowElement;
+import fiftyone.pipeline.derivedproperty.data.IDerivedPropertyData;
+
+import java.util.List;
+
+/**
+ * A Flow Element that produces new property values from property values
+ * other elements in the Pipeline have already produced, following rules
+ * written in a script, and writes them under the Element Data key
+ * {@code derived}.
+ */
+public interface IDerivedPropertyElement
+ extends FlowElement {
+
+ /**
+ * @return the metadata of every script the element holds, in the order
+ * the scripts were added
+ */
+ List getScripts();
+}
diff --git a/pipeline/pipeline.derivedproperty/src/main/java/fiftyone/pipeline/derivedproperty/flowelements/JsText.java b/pipeline/pipeline.derivedproperty/src/main/java/fiftyone/pipeline/derivedproperty/flowelements/JsText.java
new file mode 100644
index 000000000..33c602fa7
--- /dev/null
+++ b/pipeline/pipeline.derivedproperty/src/main/java/fiftyone/pipeline/derivedproperty/flowelements/JsText.java
@@ -0,0 +1,181 @@
+/* *********************************************************************
+ * This Original Work is copyright of 51 Degrees Mobile Experts Limited.
+ * Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House,
+ * Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU.
+ *
+ * This Original Work is licensed under the European Union Public Licence
+ * (EUPL) v.1.2 and is subject to its terms as set out below.
+ *
+ * If a copy of the EUPL was not distributed with this file, You can obtain
+ * one at https://opensource.org/licenses/EUPL-1.2.
+ *
+ * The 'Compatible Licences' set out in the Appendix to the EUPL (as may be
+ * amended by the European Commission) shall be deemed incompatible for
+ * the purposes of the Work and the provisions of the compatibility
+ * clause in Article 5 of the EUPL shall not apply.
+ *
+ * If using the Work as, or as part of, a network application, by
+ * including the attribution notice(s) required under Article 5 of the EUPL
+ * in the end user terms of the application under an appropriate heading,
+ * such notice(s) shall fulfill the requirements of that article.
+ * ********************************************************************* */
+
+package fiftyone.pipeline.derivedproperty.flowelements;
+
+import java.math.BigDecimal;
+
+/**
+ * Text written the way the JavaScript reference tools write it, so that a
+ * message, a number read as a string and the canonical form read the same
+ * in every language.
+ */
+final class JsText {
+
+ /**
+ * From this size up, and below {@link #SMALLEST_PLAIN}, JavaScript
+ * writes a number with an exponent.
+ */
+ private static final double LARGEST_PLAIN = 1e21;
+
+ private static final double SMALLEST_PLAIN = 1e-6;
+
+ private static final char LAST_CONTROL = 0x1f;
+ private static final char VERTICAL_TAB = 0x0b;
+ private static final String HEX_DIGITS = "0123456789abcdef";
+ private static final int NIBBLE_BITS = 4;
+ private static final int NIBBLE_MASK = 0xf;
+ private static final int UNICODE_ESCAPE_DIGITS = 4;
+
+ private JsText() {
+ }
+
+ /**
+ * Write a number as JavaScript's String does.
+ * @param value the number
+ * @return the text
+ */
+ static String number(double value) {
+ if (Double.isNaN(value)) {
+ return "NaN";
+ }
+ if (Double.isInfinite(value)) {
+ return value > 0 ? "Infinity" : "-Infinity";
+ }
+ if (value == 0) {
+ return "0";
+ }
+ BigDecimal decimal =
+ new BigDecimal(Double.toString(value)).stripTrailingZeros();
+ double size = Math.abs(value);
+ if (size >= SMALLEST_PLAIN && size < LARGEST_PLAIN) {
+ return decimal.toPlainString();
+ }
+ String digits = decimal.unscaledValue().abs().toString();
+ int exponent = digits.length() - 1 - decimal.scale();
+ StringBuilder text = new StringBuilder();
+ if (value < 0) {
+ text.append('-');
+ }
+ text.append(digits.charAt(0));
+ if (digits.length() > 1) {
+ text.append('.').append(digits, 1, digits.length());
+ }
+ text.append('e').append(exponent < 0 ? '-' : '+')
+ .append(Math.abs(exponent));
+ return text.toString();
+ }
+
+ /**
+ * Quote a string as JSON.stringify does.
+ * @param value the string
+ * @return the quoted string
+ */
+ static String quote(String value) {
+ StringBuilder text = new StringBuilder(value.length() + 2);
+ text.append('"');
+ for (int i = 0; i < value.length(); i++) {
+ char c = value.charAt(i);
+ switch (c) {
+ case '"':
+ text.append("\\\"");
+ break;
+ case '\\':
+ text.append("\\\\");
+ break;
+ case '\b':
+ text.append("\\b");
+ break;
+ case '\f':
+ text.append("\\f");
+ break;
+ case '\n':
+ text.append("\\n");
+ break;
+ case '\r':
+ text.append("\\r");
+ break;
+ case '\t':
+ text.append("\\t");
+ break;
+ default:
+ if (c <= LAST_CONTROL || isLoneSurrogate(value, i)) {
+ unicodeEscape(text, c);
+ } else {
+ text.append(c);
+ }
+ break;
+ }
+ }
+ return text.append('"').toString();
+ }
+
+ /**
+ * Whether a character is white space as JavaScript's trim reads it.
+ * @param c the character
+ * @return true for white space or a line terminator
+ */
+ static boolean isWhitespace(char c) {
+ return c == '\t' || c == '\n' || c == VERTICAL_TAB || c == '\f' ||
+ c == '\r' || c == '
' || c == '
' || c == '' ||
+ Character.getType(c) == Character.SPACE_SEPARATOR;
+ }
+
+ /**
+ * Remove leading and trailing white space as JavaScript's trim does.
+ * @param value the text
+ * @return the text without the white space around it
+ */
+ static String trim(String value) {
+ int start = 0;
+ int end = value.length();
+ while (start < end && isWhitespace(value.charAt(start))) {
+ start++;
+ }
+ while (end > start && isWhitespace(value.charAt(end - 1))) {
+ end--;
+ }
+ return value.substring(start, end);
+ }
+
+ private static boolean isLoneSurrogate(String value, int i) {
+ char c = value.charAt(i);
+ if (Character.isHighSurrogate(c)) {
+ return i + 1 >= value.length() ||
+ Character.isLowSurrogate(value.charAt(i + 1)) == false;
+ }
+ if (Character.isLowSurrogate(c)) {
+ return i == 0 ||
+ Character.isHighSurrogate(value.charAt(i - 1)) == false;
+ }
+ return false;
+ }
+
+ private static void unicodeEscape(StringBuilder text, char c) {
+ text.append("\\u");
+ for (int shift = (UNICODE_ESCAPE_DIGITS - 1) * NIBBLE_BITS;
+ shift >= 0;
+ shift -= NIBBLE_BITS) {
+ text.append(HEX_DIGITS.charAt((c >> shift) & NIBBLE_MASK));
+ }
+ }
+}
diff --git a/pipeline/pipeline.derivedproperty/src/main/java/fiftyone/pipeline/derivedproperty/flowelements/Literal.java b/pipeline/pipeline.derivedproperty/src/main/java/fiftyone/pipeline/derivedproperty/flowelements/Literal.java
new file mode 100644
index 000000000..3682ccfe4
--- /dev/null
+++ b/pipeline/pipeline.derivedproperty/src/main/java/fiftyone/pipeline/derivedproperty/flowelements/Literal.java
@@ -0,0 +1,93 @@
+/* *********************************************************************
+ * This Original Work is copyright of 51 Degrees Mobile Experts Limited.
+ * Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House,
+ * Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU.
+ *
+ * This Original Work is licensed under the European Union Public Licence
+ * (EUPL) v.1.2 and is subject to its terms as set out below.
+ *
+ * If a copy of the EUPL was not distributed with this file, You can obtain
+ * one at https://opensource.org/licenses/EUPL-1.2.
+ *
+ * The 'Compatible Licences' set out in the Appendix to the EUPL (as may be
+ * amended by the European Commission) shall be deemed incompatible for
+ * the purposes of the Work and the provisions of the compatibility
+ * clause in Article 5 of the EUPL shall not apply.
+ *
+ * If using the Work as, or as part of, a network application, by
+ * including the attribution notice(s) required under Article 5 of the EUPL
+ * in the end user terms of the application under an appropriate heading,
+ * such notice(s) shall fulfill the requirements of that article.
+ * ********************************************************************* */
+
+package fiftyone.pipeline.derivedproperty.flowelements;
+
+/**
+ * A scalar from a script, being a boolean, a number or a string.
+ */
+final class Literal {
+
+ /**
+ * What a value the validator refused becomes. The model holding it is
+ * discarded before anything reads it.
+ */
+ static final Literal NONE =
+ new Literal(ScriptNode.Kind.NULL, false, 0, "");
+
+ private final ScriptNode.Kind kind;
+ private final boolean bool;
+ private final double number;
+ private final String text;
+
+ private Literal(
+ ScriptNode.Kind kind,
+ boolean bool,
+ double number,
+ String text) {
+ this.kind = kind;
+ this.bool = bool;
+ this.number = number;
+ this.text = text;
+ }
+
+ static Literal of(ScriptNode node) {
+ return new Literal(
+ node.getKind(),
+ node.getBool(),
+ node.getNumber(),
+ node.getText());
+ }
+
+ ScriptNode.Kind getKind() {
+ return kind;
+ }
+
+ boolean getBool() {
+ return bool;
+ }
+
+ double getNumber() {
+ return number;
+ }
+
+ String getText() {
+ return text;
+ }
+
+ /**
+ * The string form of the literal, as JavaScript's String gives it,
+ * which is how a rule's value is compared with Output.Values.
+ * @return the string form
+ */
+ @Override
+ public String toString() {
+ switch (kind) {
+ case BOOL:
+ return bool ? "true" : "false";
+ case NUMBER:
+ return JsText.number(number);
+ default:
+ return text;
+ }
+ }
+}
diff --git a/pipeline/pipeline.derivedproperty/src/main/java/fiftyone/pipeline/derivedproperty/flowelements/Operator.java b/pipeline/pipeline.derivedproperty/src/main/java/fiftyone/pipeline/derivedproperty/flowelements/Operator.java
new file mode 100644
index 000000000..6deb51f67
--- /dev/null
+++ b/pipeline/pipeline.derivedproperty/src/main/java/fiftyone/pipeline/derivedproperty/flowelements/Operator.java
@@ -0,0 +1,106 @@
+/* *********************************************************************
+ * This Original Work is copyright of 51 Degrees Mobile Experts Limited.
+ * Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House,
+ * Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU.
+ *
+ * This Original Work is licensed under the European Union Public Licence
+ * (EUPL) v.1.2 and is subject to its terms as set out below.
+ *
+ * If a copy of the EUPL was not distributed with this file, You can obtain
+ * one at https://opensource.org/licenses/EUPL-1.2.
+ *
+ * The 'Compatible Licences' set out in the Appendix to the EUPL (as may be
+ * amended by the European Commission) shall be deemed incompatible for
+ * the purposes of the Work and the provisions of the compatibility
+ * clause in Article 5 of the EUPL shall not apply.
+ *
+ * If using the Work as, or as part of, a network application, by
+ * including the attribution notice(s) required under Article 5 of the EUPL
+ * in the end user terms of the application under an appropriate heading,
+ * such notice(s) shall fulfill the requirements of that article.
+ * ********************************************************************* */
+
+package fiftyone.pipeline.derivedproperty.flowelements;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * The operators of format 1, each with the types it is allowed on.
+ */
+enum Operator {
+ EQ("Eq", ValueType.BOOL, ValueType.INT, ValueType.DOUBLE,
+ ValueType.STRING),
+ NE("Ne", ValueType.BOOL, ValueType.INT, ValueType.DOUBLE,
+ ValueType.STRING),
+ GT("Gt", ValueType.INT, ValueType.DOUBLE),
+ GE("Ge", ValueType.INT, ValueType.DOUBLE),
+ LT("Lt", ValueType.INT, ValueType.DOUBLE),
+ LE("Le", ValueType.INT, ValueType.DOUBLE),
+ IN("In", ValueType.BOOL, ValueType.INT, ValueType.DOUBLE,
+ ValueType.STRING),
+ NOT_IN("NotIn", ValueType.BOOL, ValueType.INT, ValueType.DOUBLE,
+ ValueType.STRING),
+ STARTS_WITH("StartsWith", ValueType.STRING),
+ ENDS_WITH("EndsWith", ValueType.STRING),
+ CONTAINS("Contains", ValueType.STRING);
+
+ private final String text;
+ private final List types;
+
+ Operator(String text, ValueType... types) {
+ this.text = text;
+ this.types = Collections.unmodifiableList(Arrays.asList(types));
+ }
+
+ /**
+ * @return the types the operator is allowed on
+ */
+ List getTypes() {
+ return types;
+ }
+
+ /**
+ * @return true for In and NotIn, which take a list
+ */
+ boolean takesList() {
+ return this == IN || this == NOT_IN;
+ }
+
+ /**
+ * The operator a key names, matched without regard to case.
+ * @param key the key in the script
+ * @return the operator, or null where format 1 has no such operator
+ */
+ static Operator fromText(String key) {
+ for (Operator operator : values()) {
+ if (operator.text.equalsIgnoreCase(key)) {
+ return operator;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * @return every operator's name, separated by a comma and a space
+ */
+ static String names() {
+ StringBuilder names = new StringBuilder();
+ for (Operator operator : values()) {
+ if (names.length() > 0) {
+ names.append(", ");
+ }
+ names.append(operator.text);
+ }
+ return names.toString();
+ }
+
+ /**
+ * @return the name format 1 gives the operator, such as Eq
+ */
+ @Override
+ public String toString() {
+ return text;
+ }
+}
diff --git a/pipeline/pipeline.derivedproperty/src/main/java/fiftyone/pipeline/derivedproperty/flowelements/OutputModel.java b/pipeline/pipeline.derivedproperty/src/main/java/fiftyone/pipeline/derivedproperty/flowelements/OutputModel.java
new file mode 100644
index 000000000..795eb3e88
--- /dev/null
+++ b/pipeline/pipeline.derivedproperty/src/main/java/fiftyone/pipeline/derivedproperty/flowelements/OutputModel.java
@@ -0,0 +1,230 @@
+/* *********************************************************************
+ * This Original Work is copyright of 51 Degrees Mobile Experts Limited.
+ * Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House,
+ * Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU.
+ *
+ * This Original Work is licensed under the European Union Public Licence
+ * (EUPL) v.1.2 and is subject to its terms as set out below.
+ *
+ * If a copy of the EUPL was not distributed with this file, You can obtain
+ * one at https://opensource.org/licenses/EUPL-1.2.
+ *
+ * The 'Compatible Licences' set out in the Appendix to the EUPL (as may be
+ * amended by the European Commission) shall be deemed incompatible for
+ * the purposes of the Work and the provisions of the compatibility
+ * clause in Article 5 of the EUPL shall not apply.
+ *
+ * If using the Work as, or as part of, a network application, by
+ * including the attribution notice(s) required under Article 5 of the EUPL
+ * in the end user terms of the application under an appropriate heading,
+ * such notice(s) shall fulfill the requirements of that article.
+ * ********************************************************************* */
+
+package fiftyone.pipeline.derivedproperty.flowelements;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * The Output block of a script, which is the whole definition of the
+ * derived property. A field the script does not give is null.
+ */
+final class OutputModel {
+
+ /**
+ * One entry of Output.Values.
+ */
+ static final class ValueEntry {
+ private final Literal name;
+ private final String description;
+
+ ValueEntry(Literal name, String description) {
+ this.name = name;
+ this.description = description;
+ }
+
+ Literal getName() {
+ return name;
+ }
+
+ /**
+ * @return the description, or null where the script gives none
+ */
+ String getDescription() {
+ return description;
+ }
+ }
+
+ /**
+ * The Output block while the validator reads it.
+ */
+ static final class Draft {
+ String name = "";
+ String description = "";
+ ValueType valueType;
+ String storedValueType;
+ String defaultValue;
+ boolean isList;
+ Boolean isMandatory;
+ Boolean isObsolete;
+ String category;
+ Boolean isPopular;
+ Boolean exportValues;
+ String url;
+ Long displayOrder;
+ Long propertyId;
+ ScriptNode vendorIds;
+ ScriptNode dependencies;
+ final List values = new ArrayList<>();
+ boolean hasValues;
+
+ /**
+ * Whether the string form of a value is one of the names under
+ * Output.Values.
+ */
+ boolean listsValue(String value) {
+ for (ValueEntry entry : values) {
+ if (entry.getName().toString().equals(value)) {
+ return true;
+ }
+ }
+ return false;
+ }
+ }
+
+ private final String name;
+ private final String description;
+ private final ValueType valueType;
+ private final String storedValueType;
+ private final String defaultValue;
+ private final boolean isList;
+ private final Boolean isMandatory;
+ private final Boolean isObsolete;
+ private final String category;
+ private final Boolean isPopular;
+ private final Boolean exportValues;
+ private final String url;
+ private final Long displayOrder;
+ private final Long propertyId;
+ private final ScriptNode vendorIds;
+ private final ScriptNode dependencies;
+ private final List computed;
+ private final List values;
+
+ /**
+ * @param draft the block as read
+ * @param computed every source property the script names, used where
+ * the script gives no Dependencies
+ */
+ OutputModel(Draft draft, List computed) {
+ this.name = draft.name;
+ this.description = draft.description;
+ this.valueType = draft.valueType;
+ this.storedValueType = draft.storedValueType;
+ this.defaultValue = draft.defaultValue;
+ this.isList = draft.isList;
+ this.isMandatory = draft.isMandatory;
+ this.isObsolete = draft.isObsolete;
+ this.category = draft.category;
+ this.isPopular = draft.isPopular;
+ this.exportValues = draft.exportValues;
+ this.url = draft.url;
+ this.displayOrder = draft.displayOrder;
+ this.propertyId = draft.propertyId;
+ this.vendorIds = draft.vendorIds;
+ this.dependencies = draft.dependencies;
+ this.computed = Collections.unmodifiableList(
+ new ArrayList<>(computed));
+ this.values = draft.hasValues ?
+ Collections.unmodifiableList(new ArrayList<>(draft.values)) :
+ null;
+ }
+
+ String getName() {
+ return name;
+ }
+
+ String getDescription() {
+ return description;
+ }
+
+ ValueType getValueType() {
+ return valueType;
+ }
+
+ String getStoredValueType() {
+ return storedValueType;
+ }
+
+ String getDefaultValue() {
+ return defaultValue;
+ }
+
+ boolean isList() {
+ return isList;
+ }
+
+ Boolean getIsMandatory() {
+ return isMandatory;
+ }
+
+ Boolean getIsObsolete() {
+ return isObsolete;
+ }
+
+ String getCategory() {
+ return category;
+ }
+
+ Boolean getIsPopular() {
+ return isPopular;
+ }
+
+ Boolean getExportValues() {
+ return exportValues;
+ }
+
+ String getUrl() {
+ return url;
+ }
+
+ Long getDisplayOrder() {
+ return displayOrder;
+ }
+
+ Long getPropertyId() {
+ return propertyId;
+ }
+
+ /**
+ * @return the VendorIds list as written, or null
+ */
+ ScriptNode getVendorIds() {
+ return vendorIds;
+ }
+
+ /**
+ * @return the Dependencies list as written, or null where the script
+ * gives none and {@link #getComputed()} holds them
+ */
+ ScriptNode getDependencies() {
+ return dependencies;
+ }
+
+ /**
+ * @return every source property the checks and the rules name, in
+ * elementKey.PropertyName form
+ */
+ List getComputed() {
+ return computed;
+ }
+
+ /**
+ * @return the entries of Output.Values, or null where the script gives
+ * no list
+ */
+ List getValues() {
+ return values;
+ }
+}
diff --git a/pipeline/pipeline.derivedproperty/src/main/java/fiftyone/pipeline/derivedproperty/flowelements/ScriptNode.java b/pipeline/pipeline.derivedproperty/src/main/java/fiftyone/pipeline/derivedproperty/flowelements/ScriptNode.java
new file mode 100644
index 000000000..800d506e0
--- /dev/null
+++ b/pipeline/pipeline.derivedproperty/src/main/java/fiftyone/pipeline/derivedproperty/flowelements/ScriptNode.java
@@ -0,0 +1,193 @@
+/* *********************************************************************
+ * This Original Work is copyright of 51 Degrees Mobile Experts Limited.
+ * Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House,
+ * Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU.
+ *
+ * This Original Work is licensed under the European Union Public Licence
+ * (EUPL) v.1.2 and is subject to its terms as set out below.
+ *
+ * If a copy of the EUPL was not distributed with this file, You can obtain
+ * one at https://opensource.org/licenses/EUPL-1.2.
+ *
+ * The 'Compatible Licences' set out in the Appendix to the EUPL (as may be
+ * amended by the European Commission) shall be deemed incompatible for
+ * the purposes of the Work and the provisions of the compatibility
+ * clause in Article 5 of the EUPL shall not apply.
+ *
+ * If using the Work as, or as part of, a network application, by
+ * including the attribution notice(s) required under Article 5 of the EUPL
+ * in the end user terms of the application under an appropriate heading,
+ * such notice(s) shall fulfill the requirements of that article.
+ * ********************************************************************* */
+
+package fiftyone.pipeline.derivedproperty.flowelements;
+
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * One value of a parsed script. A mapping keeps its keys in the order they
+ * are written, because checks are numbered and faults reported in that
+ * order. Numbers are held as doubles, because format 1 infers a number's
+ * type from its value rather than from how it was written.
+ */
+final class ScriptNode {
+
+ /**
+ * The kinds of value a node holds.
+ */
+ enum Kind { NULL, BOOL, NUMBER, STRING, LIST, MAP }
+
+ private final Kind kind;
+ private final boolean bool;
+ private final double number;
+ private final String text;
+ private final List keys;
+ private final List values;
+ private final List items;
+ private final int line;
+
+ private ScriptNode(
+ Kind kind,
+ boolean bool,
+ double number,
+ String text,
+ List keys,
+ List values,
+ List items,
+ int line) {
+ this.kind = kind;
+ this.bool = bool;
+ this.number = number;
+ this.text = text;
+ this.keys = keys;
+ this.values = values;
+ this.items = items;
+ this.line = line;
+ }
+
+ static ScriptNode ofNull(int line) {
+ return new ScriptNode(Kind.NULL, false, 0, "",
+ Collections.emptyList(),
+ Collections.emptyList(),
+ Collections.emptyList(), line);
+ }
+
+ static ScriptNode ofBool(boolean value, int line) {
+ return new ScriptNode(Kind.BOOL, value, 0, "",
+ Collections.emptyList(),
+ Collections.emptyList(),
+ Collections.emptyList(), line);
+ }
+
+ static ScriptNode ofNumber(double value, int line) {
+ return new ScriptNode(Kind.NUMBER, false, value, "",
+ Collections.emptyList(),
+ Collections.emptyList(),
+ Collections.emptyList(), line);
+ }
+
+ static ScriptNode ofString(String value, int line) {
+ return new ScriptNode(Kind.STRING, false, 0, value,
+ Collections.emptyList(),
+ Collections.emptyList(),
+ Collections.emptyList(), line);
+ }
+
+ static ScriptNode ofList(List items, int line) {
+ return new ScriptNode(Kind.LIST, false, 0, "",
+ Collections.emptyList(),
+ Collections.emptyList(),
+ Collections.unmodifiableList(items), line);
+ }
+
+ static ScriptNode ofMap(
+ List keys,
+ List values,
+ int line) {
+ return new ScriptNode(Kind.MAP, false, 0, "",
+ Collections.unmodifiableList(keys),
+ Collections.unmodifiableList(values),
+ Collections.emptyList(), line);
+ }
+
+ Kind getKind() {
+ return kind;
+ }
+
+ boolean getBool() {
+ return bool;
+ }
+
+ double getNumber() {
+ return number;
+ }
+
+ /**
+ * @return the text of a string, or empty for any other kind
+ */
+ String getText() {
+ return text;
+ }
+
+ /**
+ * @return the keys of a mapping, in the order written
+ */
+ List getKeys() {
+ return keys;
+ }
+
+ /**
+ * @return the values of a mapping, in the order of its keys
+ */
+ List getValues() {
+ return values;
+ }
+
+ /**
+ * @return the members of a list
+ */
+ List getItems() {
+ return items;
+ }
+
+ /**
+ * @return the one based line the value starts on, or 0 where unknown
+ */
+ int getLine() {
+ return line;
+ }
+
+ boolean isCollection() {
+ return kind == Kind.MAP || kind == Kind.LIST;
+ }
+
+ /**
+ * The value of a mapping's key, matched without regard to case.
+ * @param name the key
+ * @return the value, or null where this is not a mapping or has no
+ * such key
+ */
+ ScriptNode get(String name) {
+ for (int i = 0; i < keys.size(); i++) {
+ if (keys.get(i).equalsIgnoreCase(name)) {
+ return values.get(i);
+ }
+ }
+ return null;
+ }
+
+ /**
+ * The value of a mapping's key, matched exactly as written.
+ * @param key the key
+ * @return the value, or null where there is no such key
+ */
+ ScriptNode valueOf(String key) {
+ for (int i = 0; i < keys.size(); i++) {
+ if (keys.get(i).equals(key)) {
+ return values.get(i);
+ }
+ }
+ return null;
+ }
+}
diff --git a/pipeline/pipeline.derivedproperty/src/main/java/fiftyone/pipeline/derivedproperty/flowelements/ScriptParseException.java b/pipeline/pipeline.derivedproperty/src/main/java/fiftyone/pipeline/derivedproperty/flowelements/ScriptParseException.java
new file mode 100644
index 000000000..12f245323
--- /dev/null
+++ b/pipeline/pipeline.derivedproperty/src/main/java/fiftyone/pipeline/derivedproperty/flowelements/ScriptParseException.java
@@ -0,0 +1,45 @@
+/* *********************************************************************
+ * This Original Work is copyright of 51 Degrees Mobile Experts Limited.
+ * Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House,
+ * Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU.
+ *
+ * This Original Work is licensed under the European Union Public Licence
+ * (EUPL) v.1.2 and is subject to its terms as set out below.
+ *
+ * If a copy of the EUPL was not distributed with this file, You can obtain
+ * one at https://opensource.org/licenses/EUPL-1.2.
+ *
+ * The 'Compatible Licences' set out in the Appendix to the EUPL (as may be
+ * amended by the European Commission) shall be deemed incompatible for
+ * the purposes of the Work and the provisions of the compatibility
+ * clause in Article 5 of the EUPL shall not apply.
+ *
+ * If using the Work as, or as part of, a network application, by
+ * including the attribution notice(s) required under Article 5 of the EUPL
+ * in the end user terms of the application under an appropriate heading,
+ * such notice(s) shall fulfill the requirements of that article.
+ * ********************************************************************* */
+
+package fiftyone.pipeline.derivedproperty.flowelements;
+
+/**
+ * Text that is not YAML or JSON, or is not a mapping at the top level.
+ */
+final class ScriptParseException extends Exception {
+
+ private static final long serialVersionUID = 1L;
+
+ private final int line;
+
+ ScriptParseException(String message, int line) {
+ super(message);
+ this.line = line;
+ }
+
+ /**
+ * @return the one based line, or 0 where the parser gave none
+ */
+ int getLine() {
+ return line;
+ }
+}
diff --git a/pipeline/pipeline.derivedproperty/src/main/java/fiftyone/pipeline/derivedproperty/flowelements/Slot.java b/pipeline/pipeline.derivedproperty/src/main/java/fiftyone/pipeline/derivedproperty/flowelements/Slot.java
new file mode 100644
index 000000000..ffae41a17
--- /dev/null
+++ b/pipeline/pipeline.derivedproperty/src/main/java/fiftyone/pipeline/derivedproperty/flowelements/Slot.java
@@ -0,0 +1,108 @@
+/* *********************************************************************
+ * This Original Work is copyright of 51 Degrees Mobile Experts Limited.
+ * Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House,
+ * Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU.
+ *
+ * This Original Work is licensed under the European Union Public Licence
+ * (EUPL) v.1.2 and is subject to its terms as set out below.
+ *
+ * If a copy of the EUPL was not distributed with this file, You can obtain
+ * one at https://opensource.org/licenses/EUPL-1.2.
+ *
+ * The 'Compatible Licences' set out in the Appendix to the EUPL (as may be
+ * amended by the European Commission) shall be deemed incompatible for
+ * the purposes of the Work and the provisions of the compatibility
+ * clause in Article 5 of the EUPL shall not apply.
+ *
+ * If using the Work as, or as part of, a network application, by
+ * including the attribution notice(s) required under Article 5 of the EUPL
+ * in the end user terms of the application under an appropriate heading,
+ * such notice(s) shall fulfill the requirements of that article.
+ * ********************************************************************* */
+
+package fiftyone.pipeline.derivedproperty.flowelements;
+
+/**
+ * One source property as read for one request, converted to the type the
+ * script infers for it, or the reason it could not be read.
+ */
+final class Slot {
+
+ private final boolean available;
+ private final boolean bool;
+ private final long integer;
+ private final double real;
+ private final String text;
+ private final String reason;
+
+ private Slot(
+ boolean available,
+ boolean bool,
+ long integer,
+ double real,
+ String text,
+ String reason) {
+ this.available = available;
+ this.bool = bool;
+ this.integer = integer;
+ this.real = real;
+ this.text = text;
+ this.reason = reason;
+ }
+
+ static Slot ofBool(boolean value) {
+ return new Slot(true, value, 0, 0, null, null);
+ }
+
+ static Slot ofInt(long value) {
+ return new Slot(true, false, value, 0, null, null);
+ }
+
+ static Slot ofDouble(double value) {
+ return new Slot(true, false, 0, value, null, null);
+ }
+
+ static Slot ofString(String value) {
+ return new Slot(true, false, 0, 0, value, null);
+ }
+
+ static Slot absent(String reason) {
+ return new Slot(false, false, 0, 0, null, reason);
+ }
+
+ boolean isAvailable() {
+ return available;
+ }
+
+ boolean getBool() {
+ return bool;
+ }
+
+ long getInt() {
+ return integer;
+ }
+
+ double getDouble() {
+ return real;
+ }
+
+ String getString() {
+ return text;
+ }
+
+ /**
+ * @return why the property could not be read, or null where it was
+ */
+ String getReason() {
+ return reason;
+ }
+
+ /**
+ * The value as a number, for a property read as int or double.
+ * @param type the type the property is read as
+ * @return the value
+ */
+ double number(ValueType type) {
+ return type == ValueType.INT ? integer : real;
+ }
+}
diff --git a/pipeline/pipeline.derivedproperty/src/main/java/fiftyone/pipeline/derivedproperty/flowelements/SourceProperty.java b/pipeline/pipeline.derivedproperty/src/main/java/fiftyone/pipeline/derivedproperty/flowelements/SourceProperty.java
new file mode 100644
index 000000000..30ff72d30
--- /dev/null
+++ b/pipeline/pipeline.derivedproperty/src/main/java/fiftyone/pipeline/derivedproperty/flowelements/SourceProperty.java
@@ -0,0 +1,93 @@
+/* *********************************************************************
+ * This Original Work is copyright of 51 Degrees Mobile Experts Limited.
+ * Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House,
+ * Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU.
+ *
+ * This Original Work is licensed under the European Union Public Licence
+ * (EUPL) v.1.2 and is subject to its terms as set out below.
+ *
+ * If a copy of the EUPL was not distributed with this file, You can obtain
+ * one at https://opensource.org/licenses/EUPL-1.2.
+ *
+ * The 'Compatible Licences' set out in the Appendix to the EUPL (as may be
+ * amended by the European Commission) shall be deemed incompatible for
+ * the purposes of the Work and the provisions of the compatibility
+ * clause in Article 5 of the EUPL shall not apply.
+ *
+ * If using the Work as, or as part of, a network application, by
+ * including the attribution notice(s) required under Article 5 of the EUPL
+ * in the end user terms of the application under an appropriate heading,
+ * such notice(s) shall fulfill the requirements of that article.
+ * ********************************************************************* */
+
+package fiftyone.pipeline.derivedproperty.flowelements;
+
+import java.util.Locale;
+
+/**
+ * A property another element produces, which a script reads as the type it
+ * infers from the literals compared with it.
+ */
+final class SourceProperty {
+
+ private final String key;
+ private final String name;
+ private final ValueType type;
+ private final String elementKey;
+ private final String writtenKey;
+ private final String propertyName;
+
+ /**
+ * @param name the name as the script first writes it, being
+ * elementKey.PropertyName
+ * @param type the type inferred for it
+ */
+ SourceProperty(String name, ValueType type) {
+ int dot = name.indexOf('.');
+ this.key = name.toLowerCase(Locale.ROOT);
+ this.name = name;
+ this.type = type;
+ this.writtenKey = name.substring(0, dot);
+ this.elementKey = writtenKey.toLowerCase(Locale.ROOT);
+ this.propertyName = name.substring(dot + 1);
+ }
+
+ /**
+ * @return the name in lower case, which identifies the property
+ */
+ String getKey() {
+ return key;
+ }
+
+ /**
+ * @return the name as the script first writes it
+ */
+ String getName() {
+ return name;
+ }
+
+ ValueType getType() {
+ return type;
+ }
+
+ /**
+ * @return the Element Data key, in lower case
+ */
+ String getElementKey() {
+ return elementKey;
+ }
+
+ /**
+ * @return the Element Data key as the script writes it
+ */
+ String getWrittenKey() {
+ return writtenKey;
+ }
+
+ /**
+ * @return the property half of the name, as written
+ */
+ String getPropertyName() {
+ return propertyName;
+ }
+}
diff --git a/pipeline/pipeline.derivedproperty/src/main/java/fiftyone/pipeline/derivedproperty/flowelements/SourceRead.java b/pipeline/pipeline.derivedproperty/src/main/java/fiftyone/pipeline/derivedproperty/flowelements/SourceRead.java
new file mode 100644
index 000000000..67fcd0786
--- /dev/null
+++ b/pipeline/pipeline.derivedproperty/src/main/java/fiftyone/pipeline/derivedproperty/flowelements/SourceRead.java
@@ -0,0 +1,52 @@
+/* *********************************************************************
+ * This Original Work is copyright of 51 Degrees Mobile Experts Limited.
+ * Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House,
+ * Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU.
+ *
+ * This Original Work is licensed under the European Union Public Licence
+ * (EUPL) v.1.2 and is subject to its terms as set out below.
+ *
+ * If a copy of the EUPL was not distributed with this file, You can obtain
+ * one at https://opensource.org/licenses/EUPL-1.2.
+ *
+ * The 'Compatible Licences' set out in the Appendix to the EUPL (as may be
+ * amended by the European Commission) shall be deemed incompatible for
+ * the purposes of the Work and the provisions of the compatibility
+ * clause in Article 5 of the EUPL shall not apply.
+ *
+ * If using the Work as, or as part of, a network application, by
+ * including the attribution notice(s) required under Article 5 of the EUPL
+ * in the end user terms of the application under an appropriate heading,
+ * such notice(s) shall fulfill the requirements of that article.
+ * ********************************************************************* */
+
+package fiftyone.pipeline.derivedproperty.flowelements;
+
+/**
+ * One source property, read once per request as one type, however many
+ * scripts name it.
+ */
+final class SourceRead {
+
+ private final SourceProperty property;
+ private final int key;
+
+ /**
+ * @param property the property and the type it is read as
+ * @param key the index of the Element Data key among the element's
+ * keys, or -1 for a derived property, which is read when
+ * the script that names it runs
+ */
+ SourceRead(SourceProperty property, int key) {
+ this.property = property;
+ this.key = key;
+ }
+
+ SourceProperty getProperty() {
+ return property;
+ }
+
+ int getKey() {
+ return key;
+ }
+}
diff --git a/pipeline/pipeline.derivedproperty/src/main/java/fiftyone/pipeline/derivedproperty/flowelements/SourceValues.java b/pipeline/pipeline.derivedproperty/src/main/java/fiftyone/pipeline/derivedproperty/flowelements/SourceValues.java
new file mode 100644
index 000000000..f434ea5a0
--- /dev/null
+++ b/pipeline/pipeline.derivedproperty/src/main/java/fiftyone/pipeline/derivedproperty/flowelements/SourceValues.java
@@ -0,0 +1,365 @@
+/* *********************************************************************
+ * This Original Work is copyright of 51 Degrees Mobile Experts Limited.
+ * Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House,
+ * Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU.
+ *
+ * This Original Work is licensed under the European Union Public Licence
+ * (EUPL) v.1.2 and is subject to its terms as set out below.
+ *
+ * If a copy of the EUPL was not distributed with this file, You can obtain
+ * one at https://opensource.org/licenses/EUPL-1.2.
+ *
+ * The 'Compatible Licences' set out in the Appendix to the EUPL (as may be
+ * amended by the European Commission) shall be deemed incompatible for
+ * the purposes of the Work and the provisions of the compatibility
+ * clause in Article 5 of the EUPL shall not apply.
+ *
+ * If using the Work as, or as part of, a network application, by
+ * including the attribution notice(s) required under Article 5 of the EUPL
+ * in the end user terms of the application under an appropriate heading,
+ * such notice(s) shall fulfill the requirements of that article.
+ * ********************************************************************* */
+
+package fiftyone.pipeline.derivedproperty.flowelements;
+
+import fiftyone.pipeline.core.data.ElementData;
+import fiftyone.pipeline.core.data.IWeightedValue;
+import fiftyone.pipeline.core.data.WktString;
+import fiftyone.pipeline.engines.data.AspectPropertyValue;
+
+import java.math.BigInteger;
+import java.util.List;
+import java.util.Locale;
+
+/**
+ * Reads a source property from the Element Data of the element that
+ * supplies it, and converts the value to the type the script infers. A
+ * value arrives as its native type or as a string, and both are accepted,
+ * but values are never coerced loosely, so N/A, Unknown and the empty
+ * string never become false or zero.
+ *
+ * The wording of every reason is that of readSlot in tools/evaluate.mjs in
+ * the derived-properties repository. See
+ * format
+ * 1, absent and invalid source properties .
+ */
+final class SourceValues {
+
+ /**
+ * The reason given where the property is not in the Element Data, or
+ * its value is null.
+ */
+ static final String NOT_PRESENT = "property not present on this request";
+
+ /**
+ * Stands for a list whose members are not all weighted values.
+ */
+ private static final Object NOT_WEIGHTED = new Object();
+
+ /**
+ * The bits in a long, which a BigInteger must fit inside to be read as
+ * a whole number.
+ */
+ private static final int LONG_BITS = 63;
+
+ private SourceValues() {
+ }
+
+ /**
+ * Read a property from the Element Data of its source element.
+ * @param data the Element Data, or null where the request carries none
+ * under the key
+ * @param property the property
+ * @return the value, converted, or the reason it could not be read
+ */
+ static Slot read(ElementData data, SourceProperty property) {
+ if (data == null) {
+ return notAvailable(property, NOT_PRESENT);
+ }
+ Object raw;
+ try {
+ raw = data.get(property.getPropertyName());
+ } catch (RuntimeException e) {
+ return notAvailable(property, NOT_PRESENT);
+ }
+ if (raw == null) {
+ return notAvailable(property, NOT_PRESENT);
+ }
+ return readValue(raw, property);
+ }
+
+ /**
+ * Convert a value an element holds. A value with no value carries its
+ * own message, a list of weighted values gives the value with the
+ * highest weight, and any other list cannot be read where one value is
+ * needed.
+ */
+ static Slot readValue(Object value, SourceProperty property) {
+ Object raw = value;
+ if (raw instanceof AspectPropertyValue>) {
+ AspectPropertyValue> wrapped = (AspectPropertyValue>) raw;
+ if (wrapped.hasValue() == false) {
+ String message = wrapped.getNoValueMessage();
+ return notAvailable(property,
+ message == null || message.isEmpty() ?
+ NOT_PRESENT : message);
+ }
+ raw = wrapped.getValue();
+ if (raw == null) {
+ return notAvailable(property, NOT_PRESENT);
+ }
+ }
+ if (raw instanceof List>) {
+ raw = heaviest((List>) raw);
+ if (raw == NOT_WEIGHTED) {
+ return Slot.absent(
+ "held a list where a single value is needed");
+ }
+ if (raw == null) {
+ return notAvailable(property, NOT_PRESENT);
+ }
+ } else if (raw instanceof IWeightedValue>) {
+ raw = ((IWeightedValue>) raw).getValue();
+ if (raw == null) {
+ return notAvailable(property, NOT_PRESENT);
+ }
+ }
+ Slot slot = convert(raw, property.getType());
+ if (slot != null) {
+ return slot;
+ }
+ return Slot.absent("held '" + display(raw) +
+ "' which cannot be read as " + property.getType());
+ }
+
+ private static Slot notAvailable(SourceProperty property, String detail) {
+ return Slot.absent("element '" + property.getWrittenKey() +
+ "' has no value for '" + property.getPropertyName() + "': " +
+ detail);
+ }
+
+ /**
+ * The value with the highest weight from a list of weighted values, the
+ * first where two weigh the same.
+ * @return the value, or {@link #NOT_WEIGHTED} where the list is empty or
+ * any member is not a weighted value
+ */
+ private static Object heaviest(List> list) {
+ if (list.isEmpty()) {
+ return NOT_WEIGHTED;
+ }
+ IWeightedValue> best = null;
+ for (Object member : list) {
+ if (member instanceof IWeightedValue> == false) {
+ return NOT_WEIGHTED;
+ }
+ IWeightedValue> weighted = (IWeightedValue>) member;
+ if (best == null ||
+ weighted.getRawWeighting() > best.getRawWeighting()) {
+ best = weighted;
+ }
+ }
+ return best.getValue();
+ }
+
+ /**
+ * Convert a source value, which arrives as its native type or as a
+ * string.
+ * @return the value, or null where it cannot be read as the type
+ */
+ static Slot convert(Object raw, ValueType type) {
+ if (raw instanceof String) {
+ return convertString((String) raw, type);
+ }
+ if (raw instanceof WktString) {
+ String text = raw.toString();
+ return text == null ? null : convertString(text, type);
+ }
+ if (raw instanceof Boolean) {
+ boolean value = (Boolean) raw;
+ switch (type) {
+ case BOOL:
+ return Slot.ofBool(value);
+ case STRING:
+ return Slot.ofString(value ? "True" : "False");
+ default:
+ return null;
+ }
+ }
+ Long whole = wholeNumber(raw);
+ if (whole != null) {
+ long value = whole;
+ switch (type) {
+ case INT:
+ return value < ValueType.INT_MIN ||
+ value > ValueType.INT_MAX ?
+ null : Slot.ofInt(value);
+ case DOUBLE:
+ return Slot.ofDouble(value);
+ case STRING:
+ return Slot.ofString(Long.toString(value));
+ default:
+ return null;
+ }
+ }
+ if (raw instanceof Double || raw instanceof Float) {
+ double value = ((Number) raw).doubleValue();
+ switch (type) {
+ case INT:
+ return DerivedScriptValidator.isWhole(value) == false ||
+ value < ValueType.INT_MIN ||
+ value > ValueType.INT_MAX ?
+ null : Slot.ofInt((long) value);
+ case DOUBLE:
+ return Double.isInfinite(value) || Double.isNaN(value) ?
+ null : Slot.ofDouble(value);
+ case STRING:
+ return Slot.ofString(JsText.number(value));
+ default:
+ return null;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * A value of one of Java's whole number types, or null for any other.
+ */
+ private static Long wholeNumber(Object raw) {
+ if (raw instanceof Integer || raw instanceof Long ||
+ raw instanceof Short || raw instanceof Byte) {
+ return ((Number) raw).longValue();
+ }
+ if (raw instanceof BigInteger &&
+ ((BigInteger) raw).bitLength() <= LONG_BITS) {
+ return ((BigInteger) raw).longValue();
+ }
+ return null;
+ }
+
+ /**
+ * Convert the string form of a value.
+ * @return the value, or null where it cannot be read as the type
+ */
+ static Slot convertString(String raw, ValueType type) {
+ switch (type) {
+ case BOOL: {
+ String trimmed = JsText.trim(raw).toLowerCase(Locale.ROOT);
+ if (trimmed.equals("true")) {
+ return Slot.ofBool(true);
+ }
+ if (trimmed.equals("false")) {
+ return Slot.ofBool(false);
+ }
+ return null;
+ }
+ case INT: {
+ String trimmed = JsText.trim(raw);
+ if (isIntegerText(trimmed) == false) {
+ return null;
+ }
+ try {
+ long value = Long.parseLong(trimmed);
+ return value < ValueType.INT_MIN ||
+ value > ValueType.INT_MAX ?
+ null : Slot.ofInt(value);
+ } catch (NumberFormatException e) {
+ return null;
+ }
+ }
+ case DOUBLE: {
+ String trimmed = JsText.trim(raw);
+ if (isDecimalText(trimmed) == false) {
+ return null;
+ }
+ double value = Double.parseDouble(trimmed);
+ return Double.isInfinite(value) || Double.isNaN(value) ?
+ null : Slot.ofDouble(value);
+ }
+ case STRING:
+ return Slot.ofString(raw);
+ default:
+ return null;
+ }
+ }
+
+ /**
+ * Whether text is an optional sign and then ASCII digits.
+ */
+ private static boolean isIntegerText(String text) {
+ int start = startAfterSign(text);
+ return start < text.length() &&
+ digits(text, start) == text.length() - start;
+ }
+
+ /**
+ * Whether text is an optional sign, digits with a full stop as the
+ * decimal separator, and an optional exponent.
+ */
+ private static boolean isDecimalText(String text) {
+ int position = startAfterSign(text);
+ int whole = digits(text, position);
+ position += whole;
+ int fraction = 0;
+ if (position < text.length() && text.charAt(position) == '.') {
+ position++;
+ fraction = digits(text, position);
+ position += fraction;
+ }
+ if (whole == 0 && fraction == 0) {
+ return false;
+ }
+ if (position < text.length() &&
+ (text.charAt(position) == 'e' || text.charAt(position) == 'E')) {
+ position++;
+ if (position < text.length() &&
+ (text.charAt(position) == '+' ||
+ text.charAt(position) == '-')) {
+ position++;
+ }
+ int exponent = digits(text, position);
+ if (exponent == 0) {
+ return false;
+ }
+ position += exponent;
+ }
+ return position == text.length();
+ }
+
+ private static int startAfterSign(String text) {
+ return text.length() > 0 &&
+ (text.charAt(0) == '+' || text.charAt(0) == '-') ? 1 : 0;
+ }
+
+ /**
+ * The number of ASCII digits in text from start.
+ */
+ private static int digits(String text, int start) {
+ int position = start;
+ while (position < text.length() &&
+ text.charAt(position) >= '0' && text.charAt(position) <= '9') {
+ position++;
+ }
+ return position - start;
+ }
+
+ /**
+ * A source value as a message prints it.
+ */
+ private static String display(Object raw) {
+ if (raw instanceof String) {
+ return (String) raw;
+ }
+ if (raw instanceof Boolean) {
+ return (Boolean) raw ? "True" : "False";
+ }
+ Long whole = wholeNumber(raw);
+ if (whole != null) {
+ return whole.toString();
+ }
+ if (raw instanceof Double || raw instanceof Float) {
+ return JsText.number(((Number) raw).doubleValue());
+ }
+ return String.valueOf(raw);
+ }
+}
diff --git a/pipeline/pipeline.derivedproperty/src/main/java/fiftyone/pipeline/derivedproperty/flowelements/ValueType.java b/pipeline/pipeline.derivedproperty/src/main/java/fiftyone/pipeline/derivedproperty/flowelements/ValueType.java
new file mode 100644
index 000000000..6de338937
--- /dev/null
+++ b/pipeline/pipeline.derivedproperty/src/main/java/fiftyone/pipeline/derivedproperty/flowelements/ValueType.java
@@ -0,0 +1,85 @@
+/* *********************************************************************
+ * This Original Work is copyright of 51 Degrees Mobile Experts Limited.
+ * Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House,
+ * Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU.
+ *
+ * This Original Work is licensed under the European Union Public Licence
+ * (EUPL) v.1.2 and is subject to its terms as set out below.
+ *
+ * If a copy of the EUPL was not distributed with this file, You can obtain
+ * one at https://opensource.org/licenses/EUPL-1.2.
+ *
+ * The 'Compatible Licences' set out in the Appendix to the EUPL (as may be
+ * amended by the European Commission) shall be deemed incompatible for
+ * the purposes of the Work and the provisions of the compatibility
+ * clause in Article 5 of the EUPL shall not apply.
+ *
+ * If using the Work as, or as part of, a network application, by
+ * including the attribution notice(s) required under Article 5 of the EUPL
+ * in the end user terms of the application under an appropriate heading,
+ * such notice(s) shall fulfill the requirements of that article.
+ * ********************************************************************* */
+
+package fiftyone.pipeline.derivedproperty.flowelements;
+
+import java.util.Locale;
+
+/**
+ * The four value types format 1 has, each with the Java type a value of it
+ * is held as.
+ */
+enum ValueType {
+ STRING("string", String.class),
+ BOOL("bool", Boolean.class),
+ INT("int", Integer.class),
+ DOUBLE("double", Double.class);
+
+ /**
+ * The smallest value of int, which format 1 fixes at a signed 32 bit
+ * whole number so that one script gives one answer in every language.
+ */
+ static final long INT_MIN = Integer.MIN_VALUE;
+
+ /**
+ * The largest value of int.
+ */
+ static final long INT_MAX = Integer.MAX_VALUE;
+
+ private final String text;
+ private final Class> valueClass;
+
+ ValueType(String text, Class> valueClass) {
+ this.text = text;
+ this.valueClass = valueClass;
+ }
+
+ /**
+ * @return the Java type a value of this type is held as
+ */
+ Class> getValueClass() {
+ return valueClass;
+ }
+
+ /**
+ * The value type a script names, matched without regard to case.
+ * @param text the name in the script
+ * @return the type, or null where format 1 has no such type
+ */
+ static ValueType fromText(String text) {
+ String lower = text.toLowerCase(Locale.ROOT);
+ for (ValueType type : values()) {
+ if (type.text.equals(lower)) {
+ return type;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * @return the name format 1 gives the type, such as int
+ */
+ @Override
+ public String toString() {
+ return text;
+ }
+}
diff --git a/pipeline/pipeline.derivedproperty/src/test/java/fiftyone/pipeline/derivedproperty/BuiltInScriptTests.java b/pipeline/pipeline.derivedproperty/src/test/java/fiftyone/pipeline/derivedproperty/BuiltInScriptTests.java
new file mode 100644
index 000000000..f8bf3014a
--- /dev/null
+++ b/pipeline/pipeline.derivedproperty/src/test/java/fiftyone/pipeline/derivedproperty/BuiltInScriptTests.java
@@ -0,0 +1,109 @@
+/* *********************************************************************
+ * This Original Work is copyright of 51 Degrees Mobile Experts Limited.
+ * Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House,
+ * Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU.
+ *
+ * This Original Work is licensed under the European Union Public Licence
+ * (EUPL) v.1.2 and is subject to its terms as set out below.
+ *
+ * If a copy of the EUPL was not distributed with this file, You can obtain
+ * one at https://opensource.org/licenses/EUPL-1.2.
+ *
+ * The 'Compatible Licences' set out in the Appendix to the EUPL (as may be
+ * amended by the European Commission) shall be deemed incompatible for
+ * the purposes of the Work and the provisions of the compatibility
+ * clause in Article 5 of the EUPL shall not apply.
+ *
+ * If using the Work as, or as part of, a network application, by
+ * including the attribution notice(s) required under Article 5 of the EUPL
+ * in the end user terms of the application under an appropriate heading,
+ * such notice(s) shall fulfill the requirements of that article.
+ * ********************************************************************* */
+
+package fiftyone.pipeline.derivedproperty;
+
+import fiftyone.pipeline.derivedproperty.flowelements.DerivedPropertyElement;
+import fiftyone.pipeline.derivedproperty.flowelements.DerivedPropertyElementBuilder;
+import org.junit.jupiter.api.Test;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.DirectoryStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.Map;
+import java.util.TreeMap;
+import java.util.TreeSet;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * The scripts this package ships are the scripts folder of the
+ * derived-properties submodule, and {@link BuiltInScript} names every one
+ * of them, so a script added to the submodule fails this test until it has
+ * a constant.
+ */
+public class BuiltInScriptTests {
+
+ /**
+ * The scripts in the submodule, by name, with their text.
+ */
+ private static Map submoduleScripts() throws IOException {
+ Path folder = Paths.get("..", "derived-properties", "scripts");
+ assertTrue(Files.isDirectory(folder), "The derived-properties " +
+ "submodule is missing from " + folder.toAbsolutePath() +
+ ". Run 'git submodule update --init pipeline/derived-properties'.");
+ Map scripts = new TreeMap<>();
+ try (DirectoryStream stream = Files.newDirectoryStream(
+ folder, "*.{yaml,yml,json}")) {
+ for (Path file : stream) {
+ String name = file.getFileName().toString();
+ scripts.put(
+ name.substring(0, name.lastIndexOf('.')),
+ new String(Files.readAllBytes(file),
+ StandardCharsets.UTF_8));
+ }
+ }
+ return scripts;
+ }
+
+ @Test
+ public void enumNamesEveryScriptInTheSubmodule() throws IOException {
+ TreeSet names = new TreeSet<>();
+ for (BuiltInScript script : BuiltInScript.values()) {
+ names.add(script.name());
+ }
+ assertEquals(submoduleScripts().keySet(), names,
+ "BuiltInScript and the scripts folder of the derived-properties " +
+ "submodule differ. Add a constant for each script, named " +
+ "exactly as the script is.");
+ }
+
+ @Test
+ public void packagedScriptsAreTheSubmodulesScripts() throws IOException {
+ Map scripts = submoduleScripts();
+ for (BuiltInScript script : BuiltInScript.values()) {
+ String text = script.readText();
+ assertNotNull(text, script + " is not in the package.");
+ assertEquals(scripts.get(script.name()), text,
+ "The packaged " + script + " differs from the submodule's.");
+ assertSame(script, BuiltInScript.fromName(script.name()));
+ }
+ }
+
+ @Test
+ public void everyScriptBuilds() {
+ for (BuiltInScript script : BuiltInScript.values()) {
+ DerivedPropertyElement element = new DerivedPropertyElementBuilder(
+ LoggerFactory.getILoggerFactory())
+ .addScript(script)
+ .build();
+ assertEquals(script.name(), element.getScripts().get(0).getName());
+ }
+ }
+}
diff --git a/pipeline/pipeline.derivedproperty/src/test/java/fiftyone/pipeline/derivedproperty/flowelements/DerivedPropertyElementConfigurationTests.java b/pipeline/pipeline.derivedproperty/src/test/java/fiftyone/pipeline/derivedproperty/flowelements/DerivedPropertyElementConfigurationTests.java
new file mode 100644
index 000000000..6e5e73e59
--- /dev/null
+++ b/pipeline/pipeline.derivedproperty/src/test/java/fiftyone/pipeline/derivedproperty/flowelements/DerivedPropertyElementConfigurationTests.java
@@ -0,0 +1,161 @@
+/* *********************************************************************
+ * This Original Work is copyright of 51 Degrees Mobile Experts Limited.
+ * Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House,
+ * Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU.
+ *
+ * This Original Work is licensed under the European Union Public Licence
+ * (EUPL) v.1.2 and is subject to its terms as set out below.
+ *
+ * If a copy of the EUPL was not distributed with this file, You can obtain
+ * one at https://opensource.org/licenses/EUPL-1.2.
+ *
+ * The 'Compatible Licences' set out in the Appendix to the EUPL (as may be
+ * amended by the European Commission) shall be deemed incompatible for
+ * the purposes of the Work and the provisions of the compatibility
+ * clause in Article 5 of the EUPL shall not apply.
+ *
+ * If using the Work as, or as part of, a network application, by
+ * including the attribution notice(s) required under Article 5 of the EUPL
+ * in the end user terms of the application under an appropriate heading,
+ * such notice(s) shall fulfill the requirements of that article.
+ * ********************************************************************* */
+
+package fiftyone.pipeline.derivedproperty.flowelements;
+
+import fiftyone.pipeline.core.configuration.PipelineOptions;
+import fiftyone.pipeline.core.configuration.PipelineOptionsFactory;
+import fiftyone.pipeline.core.data.FlowData;
+import fiftyone.pipeline.core.exceptions.PipelineConfigurationException;
+import fiftyone.pipeline.core.flowelements.Pipeline;
+import fiftyone.pipeline.core.flowelements.PipelineBuilder;
+import fiftyone.pipeline.derivedproperty.data.IDerivedPropertyData;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.slf4j.LoggerFactory;
+
+import java.io.File;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * A configuration file names the builder as DerivedPropertyElement and
+ * passes built-in script names as Scripts and file paths as ScriptFiles,
+ * each a comma separated string.
+ *
+ * See the
+ * configuration
+ * section of the specification.
+ */
+public class DerivedPropertyElementConfigurationTests {
+
+ @TempDir
+ Path folder;
+
+ /**
+ * A file name pattern in the folder, built as text, because Windows
+ * refuses a Path that holds a wildcard.
+ */
+ private String pattern(String fileNamePattern) {
+ return folder.toString() + File.separator + fileNamePattern;
+ }
+
+ /**
+ * Writes the Band script into the folder and gives a pattern matching
+ * it.
+ */
+ private String bandFiles() throws Exception {
+ Files.write(folder.resolve("Band.yaml"),
+ DerivedPropertyElementTests.BAND_YAML.getBytes(
+ StandardCharsets.UTF_8));
+ return pattern("*.yaml");
+ }
+
+ /**
+ * Options read from an XML configuration file that puts two source
+ * elements in front of a Derived Property Element with the build
+ * parameters given.
+ */
+ private PipelineOptions options(String buildParameters)
+ throws Exception {
+ String xml = "" +
+ "StubSourceElement " +
+ "device " +
+ "BrowserReleaseAge=1,IsCrawler=False " +
+ " " +
+ "StubSourceElement " +
+ "ip " +
+ "CountryCode=FR " +
+ " " +
+ "DerivedPropertyElement " +
+ "" + buildParameters + " " +
+ " " +
+ " ";
+ Path file = folder.resolve("pipeline.xml");
+ Files.write(file, xml.getBytes(StandardCharsets.UTF_8));
+ return PipelineOptionsFactory.getOptionsFromFile(file.toFile());
+ }
+
+ @Test
+ public void scriptsAndScriptFilesFromAConfigurationFile()
+ throws Exception {
+ PipelineOptions options = options(
+ " IsGdpr " +
+ "" + bandFiles() + " ");
+ try (Pipeline pipeline =
+ new PipelineBuilder().buildFromConfiguration(options);
+ FlowData data = pipeline.createFlowData()) {
+ data.process();
+ IDerivedPropertyData derived =
+ data.get(IDerivedPropertyData.class);
+ assertEquals(Boolean.TRUE,
+ derived.getAs("IsGdpr", Boolean.class).getValue());
+ assertEquals("New",
+ derived.getAs("Band", String.class).getValue());
+ assertEquals(2, pipeline.getElement(DerivedPropertyElement.class)
+ .getScripts().size());
+ }
+ }
+
+ @Test
+ public void listsAreCommaSeparated() throws Exception {
+ DerivedPropertyElement element = new DerivedPropertyElementBuilder(
+ LoggerFactory.getILoggerFactory())
+ .setScripts("HumanConfidence, IsGdpr,")
+ .setScriptFiles(" ," + bandFiles())
+ .build();
+ assertEquals(3, element.getScripts().size());
+ }
+
+ @Test
+ public void anUnknownBuildParameterIsRefused() throws Exception {
+ PipelineOptions options = options("");
+ assertThrows(PipelineConfigurationException.class,
+ () -> new PipelineBuilder().buildFromConfiguration(options));
+ }
+
+ @Test
+ public void aPatternMatchingNoFileIsRefused() throws Exception {
+ PipelineOptions options = options("" +
+ pattern("*.json") + " ");
+ PipelineConfigurationException e = assertThrows(
+ PipelineConfigurationException.class,
+ () -> new PipelineBuilder().buildFromConfiguration(options));
+ assertTrue(e.getMessage().contains("no file matches"),
+ e.getMessage());
+ }
+
+ @Test
+ public void anUnknownBuiltInScriptIsRefused() throws Exception {
+ PipelineOptions options = options("NoSuchScript ");
+ PipelineConfigurationException e = assertThrows(
+ PipelineConfigurationException.class,
+ () -> new PipelineBuilder().buildFromConfiguration(options));
+ assertTrue(e.getMessage().contains(
+ "'NoSuchScript' is not a built-in script"), e.getMessage());
+ }
+}
diff --git a/pipeline/pipeline.derivedproperty/src/test/java/fiftyone/pipeline/derivedproperty/flowelements/DerivedPropertyElementTests.java b/pipeline/pipeline.derivedproperty/src/test/java/fiftyone/pipeline/derivedproperty/flowelements/DerivedPropertyElementTests.java
new file mode 100644
index 000000000..6b43c7e41
--- /dev/null
+++ b/pipeline/pipeline.derivedproperty/src/test/java/fiftyone/pipeline/derivedproperty/flowelements/DerivedPropertyElementTests.java
@@ -0,0 +1,605 @@
+/* *********************************************************************
+ * This Original Work is copyright of 51 Degrees Mobile Experts Limited.
+ * Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House,
+ * Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU.
+ *
+ * This Original Work is licensed under the European Union Public Licence
+ * (EUPL) v.1.2 and is subject to its terms as set out below.
+ *
+ * If a copy of the EUPL was not distributed with this file, You can obtain
+ * one at https://opensource.org/licenses/EUPL-1.2.
+ *
+ * The 'Compatible Licences' set out in the Appendix to the EUPL (as may be
+ * amended by the European Commission) shall be deemed incompatible for
+ * the purposes of the Work and the provisions of the compatibility
+ * clause in Article 5 of the EUPL shall not apply.
+ *
+ * If using the Work as, or as part of, a network application, by
+ * including the attribution notice(s) required under Article 5 of the EUPL
+ * in the end user terms of the application under an appropriate heading,
+ * such notice(s) shall fulfill the requirements of that article.
+ * ********************************************************************* */
+
+package fiftyone.pipeline.derivedproperty.flowelements;
+
+import fiftyone.pipeline.core.data.ElementPropertyMetaData;
+import fiftyone.pipeline.core.data.FlowData;
+import fiftyone.pipeline.core.exceptions.PipelineConfigurationException;
+import fiftyone.pipeline.core.flowelements.FlowElement;
+import fiftyone.pipeline.core.flowelements.Pipeline;
+import fiftyone.pipeline.derivedproperty.BuiltInScript;
+import fiftyone.pipeline.derivedproperty.DerivedScriptValidationException;
+import fiftyone.pipeline.derivedproperty.data.IDerivedPropertyData;
+import fiftyone.pipeline.engines.data.AspectPropertyValue;
+import fiftyone.pipeline.engines.exceptions.NoValueException;
+import fiftyone.pipeline.engines.exceptions.PropertyMissingException;
+import org.junit.jupiter.api.Test;
+import org.slf4j.LoggerFactory;
+import org.slf4j.event.Level;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * The Derived Property Element against the specification and format 1,
+ * using scripts written here so the tests do not depend on the content of
+ * the shared scripts.
+ */
+public class DerivedPropertyElementTests {
+
+ static final String BAND_YAML = "Format: 1\n" +
+ "Name: Band\n" +
+ "Version: 1.0.0\n" +
+ "Output:\n" +
+ " Name: Band\n" +
+ " Description: A band.\n" +
+ " ValueType: string\n" +
+ " IsList: false\n" +
+ " Values:\n" +
+ " - { Name: Old }\n" +
+ " - { Name: New, Description: Recent. }\n" +
+ "Checks:\n" +
+ " Recent: { Property: device.BrowserReleaseAge, Lt: 2.0 }\n" +
+ "Rules:\n" +
+ " - When: { All: [ { Check: Recent }, " +
+ "{ Property: device.IsCrawler, Eq: false } ] }\n" +
+ " Then: New\n" +
+ " - Else: Old\n";
+
+ static final String BAND_JSON = "{\"Format\": 1, \"Name\": \"Band\", " +
+ "\"Version\": \"1.0.0\",\n" +
+ " \"Output\": {\"Name\": \"Band\", \"Description\": \"A band.\", " +
+ "\"ValueType\": \"string\", \"IsList\": false,\n" +
+ " \"Values\": [{\"Name\": \"Old\"}, {\"Name\": \"New\", " +
+ "\"Description\": \"Recent.\"}]},\n" +
+ " \"Checks\": {\"Recent\": {\"Property\": " +
+ "\"device.BrowserReleaseAge\", \"Lt\": 2}},\n" +
+ " \"Rules\": [{\"When\": {\"All\": [{\"Check\": \"Recent\"}, " +
+ "{\"Property\": \"device.IsCrawler\", \"Eq\": false}]},\n" +
+ " \"Then\": \"New\"}, {\"Else\": \"Old\"}]}";
+
+ /**
+ * A script reading one property under device and one under ip.
+ */
+ static final String LIKELY_YAML = "Format: 1\n" +
+ "Name: Likely\n" +
+ "Version: 1.0.0\n" +
+ "Output:\n" +
+ " Name: Likely\n" +
+ " Description: Whether a person is likely to be viewing the page.\n" +
+ " ValueType: bool\n" +
+ " IsList: false\n" +
+ " Category: Human Detection\n" +
+ "Checks:\n" +
+ " NotCrawler: { Property: device.IsCrawler, Eq: false }\n" +
+ " HumanAddress: { Property: ip.HumanProbability, Ge: 7 }\n" +
+ "Rules:\n" +
+ " - When: { Failed: Checks, Eq: 0 }\n" +
+ " Then: true\n" +
+ " - Else: false\n";
+
+ private static final String MOBILE_YAML = "Format: 1\n" +
+ "Name: Mobile\n" +
+ "Version: 1.0.0\n" +
+ "Output: { Name: Mobile, Description: Whether the device is " +
+ "mobile., ValueType: bool, IsList: false }\n" +
+ "Rules:\n" +
+ " - When: { Property: device.IsMobile, Eq: true }\n" +
+ " Then: true\n" +
+ " - Else: false\n";
+
+ private static final String KIND_YAML = "Format: 1\n" +
+ "Name: Kind\n" +
+ "Version: 1.0.0\n" +
+ "Output: { Name: Kind, Description: The kind of device., " +
+ "ValueType: string, IsList: false }\n" +
+ "Rules:\n" +
+ " - When: { Property: derived.Mobile, Eq: true }\n" +
+ " Then: Phone\n" +
+ " - Else: Other\n";
+
+ /**
+ * A request the Likely script reads as true.
+ */
+ static Map> likelyValues() {
+ Map> values = new LinkedHashMap<>();
+ values.put("device", map("IsCrawler", "False"));
+ values.put("ip", map("HumanProbability", 9));
+ return values;
+ }
+
+ static Map map(Object... pairs) {
+ Map map = new LinkedHashMap<>();
+ for (int i = 0; i < pairs.length; i += 2) {
+ map.put((String) pairs[i], pairs[i + 1]);
+ }
+ return map;
+ }
+
+ static DerivedScript mustValidate(String text) {
+ DerivedScriptValidator.Result result =
+ DerivedScriptValidator.validate(text, "", "code");
+ assertTrue(result.getFaults().isEmpty(),
+ "The script did not validate:\n" + result.getFaults());
+ return result.getScript();
+ }
+
+ static DerivedPropertyElementBuilder builder() {
+ return new DerivedPropertyElementBuilder(
+ LoggerFactory.getILoggerFactory());
+ }
+
+ static DerivedPropertyElement likely() {
+ return builder().addScript("Likely", LIKELY_YAML).build();
+ }
+
+ /**
+ * A YAML script and the JSON that mirrors it give the same model, and
+ * the canonical form adds the inferred types and the computed
+ * dependencies.
+ */
+ @Test
+ public void yamlAndJsonGiveTheSameModel() {
+ String fromYaml = CanonicalJson.print(mustValidate(BAND_YAML));
+ String fromJson = CanonicalJson.print(mustValidate(BAND_JSON));
+ assertEquals(fromYaml, fromJson);
+ for (String want : new String[]{
+ "\"device.BrowserReleaseAge\": {\n \"Type\": \"int\"\n }",
+ "\"Dependencies\": [\n \"device.BrowserReleaseAge\",\n" +
+ " \"device.IsCrawler\"\n ]",
+ "\"Lt\": 2\n"}) {
+ assertTrue(fromYaml.contains(want),
+ "The canonical form lacks " + want + ":\n" + fromYaml);
+ }
+ }
+
+ /**
+ * 2 and 2.0 are both whole numbers, so they infer the same type and do
+ * not conflict.
+ */
+ @Test
+ public void wholeNumbersInferInt() {
+ DerivedScript model = mustValidate(BAND_YAML.replace("Checks:\n",
+ "Checks:\n Young: { Property: device.BrowserReleaseAge, " +
+ "Lt: 2 }\n"));
+ assertSame(ValueType.INT, model.getProperties().get(0).getType());
+ }
+
+ /**
+ * Every fault in every script is reported at once, one line each.
+ */
+ @Test
+ public void buildReportsEveryFault() {
+ DerivedScriptValidationException e = assertThrows(
+ DerivedScriptValidationException.class,
+ () -> builder()
+ .addScript("First", BAND_YAML.replace(
+ "Name: Band\nVersion", "Name: First\nVersion: x\nV"))
+ .addScript("Second", BAND_YAML.replace(
+ "Format: 1", "Format: 2"))
+ .setScripts("NoSuchScript")
+ .build());
+ String message = e.getMessage();
+ for (String want : new String[]{"First (code)", "Second (code)",
+ "Format must be 1, found 2",
+ "'NoSuchScript' is not a built-in script"}) {
+ assertTrue(message.contains(want),
+ "The faults lack " + want + ":\n" + message);
+ }
+ assertEquals(e.getFaults().size(), message.split("\n").length);
+ }
+
+ @Test
+ public void buildNeedsAScript() {
+ assertThrows(IllegalStateException.class, () -> builder().build());
+ }
+
+ @Test
+ public void twoScriptsWithOneOutputAreRefused() {
+ DerivedScriptValidationException e = assertThrows(
+ DerivedScriptValidationException.class,
+ () -> builder()
+ .addScript("Band", BAND_YAML)
+ .addScript("Band", BAND_JSON)
+ .build());
+ assertTrue(e.getMessage().contains("is added more than once"),
+ e.getMessage());
+ assertTrue(e.getMessage().contains(
+ "is also produced by the script 'Band'"), e.getMessage());
+ }
+
+ private static void assertPipelineRefused(
+ String want,
+ FlowElement, ?>... elements) {
+ TestPipelineBuilder builder = new TestPipelineBuilder();
+ for (FlowElement, ?> element : elements) {
+ builder.addFlowElement(element);
+ }
+ PipelineConfigurationException e = assertThrows(
+ PipelineConfigurationException.class, builder::build);
+ assertTrue(e.getMessage().contains(want),
+ "The build gave '" + e.getMessage() + "', not '" + want + "'");
+ }
+
+ /**
+ * The Pipeline check names a key no element has, a source property no
+ * element lists, and a derived property two elements produce.
+ */
+ @Test
+ public void pipelineCheckNamesWhatIsMissing() {
+ List device = Collections.singletonList("IsCrawler");
+ assertPipelineRefused(
+ "no element in the pipeline has the element data key 'ip'",
+ new StubSourceElement("device", device, null),
+ likely());
+ assertPipelineRefused(
+ "no element under 'ip' lists the property 'HumanProbability'",
+ new StubSourceElement("device", device, null),
+ new StubSourceElement("ip",
+ Collections.singletonList("CountryCode"), null),
+ likely());
+ assertPipelineRefused(
+ "two Derived Property Elements in the pipeline produce the " +
+ "derived property 'Likely'",
+ new StubSourceElement("device", device, null),
+ new StubSourceElement("ip",
+ Collections.singletonList("HumanProbability"), null),
+ likely(),
+ likely());
+ }
+
+ /**
+ * Where an element sits is not judged, so an element added before the
+ * elements it reads builds, and gives no value naming each property.
+ */
+ @Test
+ public void positionIsNotJudged() throws Exception {
+ DerivedPropertyElement element = likely();
+ List> elements = new ArrayList<>();
+ elements.add(element);
+ elements.addAll(SharedCasesTests.stubsFor(element, likelyValues()));
+ TestPipelineBuilder builder = new TestPipelineBuilder();
+ for (FlowElement, ?> e : elements) {
+ builder.addFlowElement(e);
+ }
+ try (Pipeline pipeline = builder.build();
+ FlowData data = pipeline.createFlowData()) {
+ data.process();
+ AspectPropertyValue value = data
+ .get(IDerivedPropertyData.class)
+ .getAs("Likely", Boolean.class);
+ SharedCasesTests.checkMissing(value,
+ Arrays.asList("device.IsCrawler", "ip.HumanProbability"),
+ "Likely");
+ }
+ }
+
+ /**
+ * Sources that run in a parallel group are listed after the elements at
+ * the top level, and the check still finds them.
+ */
+ @Test
+ public void pipelineCheckFindsAParallelGroup() throws Exception {
+ DerivedPropertyElement element = likely();
+ List stubs =
+ SharedCasesTests.stubsFor(element, likelyValues());
+ try (Pipeline pipeline = new TestPipelineBuilder()
+ .addFlowElementsParallel(
+ stubs.toArray(new FlowElement, ?>[0]))
+ .addFlowElement(element)
+ .setAutoCloseElements(true)
+ .build();
+ FlowData data = pipeline.createFlowData()) {
+ data.process();
+ assertEquals(Boolean.TRUE, data.get(IDerivedPropertyData.class)
+ .getAs("Likely", Boolean.class).getValue());
+ }
+ }
+
+ /**
+ * Elements in a parallel group write into the one Element Data under
+ * derived at the same time.
+ */
+ @Test
+ public void elementsInAParallelGroupShareTheElementData()
+ throws Exception {
+ DerivedPropertyElement mobile =
+ builder().addScript("Mobile", MOBILE_YAML).build();
+ DerivedPropertyElement band =
+ builder().addScript("Band", BAND_YAML).build();
+ Map> values = new LinkedHashMap<>();
+ values.put("device", map("IsMobile", true, "IsCrawler", false,
+ "BrowserReleaseAge", 1));
+ StubSourceElement device = new StubSourceElement("device",
+ Arrays.asList("IsMobile", "IsCrawler", "BrowserReleaseAge"),
+ values.get("device"));
+ try (Pipeline pipeline = new TestPipelineBuilder()
+ .addFlowElement(device)
+ .addFlowElementsParallel(new FlowElement, ?>[]{mobile, band})
+ .setAutoCloseElements(true)
+ .build()) {
+ for (int request = 0; request < 50; request++) {
+ try (FlowData data = pipeline.createFlowData()) {
+ data.process();
+ IDerivedPropertyData derived =
+ data.get(IDerivedPropertyData.class);
+ assertEquals(Boolean.TRUE,
+ derived.getAs("Mobile", Boolean.class).getValue());
+ assertEquals("New",
+ derived.getAs("band", String.class).getValue());
+ }
+ }
+ }
+ }
+
+ /**
+ * A request with no Element Data under a key a script reads gives no
+ * value, and the error is logged once for the script.
+ */
+ @Test
+ public void noElementDataIsLoggedOnce() throws Exception {
+ RecordingLoggerFactory logs = new RecordingLoggerFactory(Level.INFO);
+ DerivedPropertyElement element =
+ new DerivedPropertyElementBuilder(logs)
+ .addScript("Likely", LIKELY_YAML)
+ .build();
+ try (Pipeline pipeline = new TestPipelineBuilder()
+ .addFlowElement(new StubSourceElement("device",
+ Collections.singletonList("IsCrawler"),
+ map("IsCrawler", false)))
+ .addFlowElement(new StubSourceElement("ip",
+ Collections.singletonList("HumanProbability"),
+ map("HumanProbability", 9), false))
+ .addFlowElement(element)
+ .build()) {
+ for (int request = 0; request < 3; request++) {
+ try (FlowData data = pipeline.createFlowData()) {
+ data.process();
+ AspectPropertyValue value = data
+ .get(IDerivedPropertyData.class)
+ .getAs("Likely", Boolean.class);
+ assertFalse(value.hasValue());
+ assertTrue(value.getNoValueMessage().contains(
+ "'ip.HumanProbability' (element 'ip' has no value " +
+ "for 'HumanProbability': property not present " +
+ "on this request)"),
+ value.getNoValueMessage());
+ }
+ }
+ }
+ List errors = logs.messages(Level.ERROR);
+ assertEquals(1, errors.size(), errors.toString());
+ assertTrue(errors.get(0).contains("reads 'ip.HumanProbability'"),
+ errors.get(0));
+ assertTrue(errors.get(0).contains(
+ "after the element that supplies 'ip'"), errors.get(0));
+ assertTrue(errors.get(0).contains("reported once"), errors.get(0));
+ }
+
+ /**
+ * A source property absent for any other reason, such as one the
+ * JavaScript has not populated yet, is not logged.
+ */
+ @Test
+ public void anAbsentPropertyIsNotLogged() throws Exception {
+ RecordingLoggerFactory logs = new RecordingLoggerFactory(Level.INFO);
+ DerivedPropertyElement element =
+ new DerivedPropertyElementBuilder(logs)
+ .addScript("Likely", LIKELY_YAML)
+ .build();
+ Map> values = likelyValues();
+ values.get("ip").remove("HumanProbability");
+ AspectPropertyValue> value =
+ SharedCasesTests.process(element, values, "Likely");
+ assertFalse(value.hasValue());
+ assertTrue(logs.messages(Level.ERROR).isEmpty(),
+ logs.messages(Level.ERROR).toString());
+ }
+
+ /**
+ * A script can read the property another script in the same element
+ * produces, under the key derived.
+ */
+ @Test
+ public void derivedFromDerived() throws Exception {
+ DerivedPropertyElement element = builder()
+ .addScript("Mobile", MOBILE_YAML)
+ .addScript("Kind", KIND_YAML)
+ .build();
+ Map> values = new LinkedHashMap<>();
+ values.put("device", map("IsMobile", "True"));
+ AspectPropertyValue> kind =
+ SharedCasesTests.process(element, values, "Kind");
+ assertTrue(kind.hasValue(), kind.getNoValueMessage());
+ assertEquals("Phone", kind.getValue());
+ }
+
+ /**
+ * The element accepts no evidence, lists one property per script and
+ * exposes each script's full metadata.
+ */
+ @Test
+ public void metadata() {
+ DerivedPropertyElement element = builder()
+ .addScript("Band", BAND_YAML)
+ .addScript("Likely", LIKELY_YAML)
+ .build();
+ assertFalse(element.getEvidenceKeyFilter()
+ .include("header.user-agent"));
+ assertEquals("derived", element.getElementDataKey());
+ List properties = element.getProperties();
+ assertEquals(2, properties.size());
+ assertEquals("Band", properties.get(0).getName());
+ assertEquals("", properties.get(0).getCategory());
+ assertSame(String.class, properties.get(0).getType());
+ assertTrue(properties.get(0).isAvailable());
+ assertSame(element, properties.get(0).getElement());
+ assertEquals("Likely", properties.get(1).getName());
+ assertEquals("Human Detection", properties.get(1).getCategory());
+ assertSame(Boolean.class, properties.get(1).getType());
+
+ List scripts = element.getScripts();
+ assertEquals(2, scripts.size());
+ DerivedPropertyMetaData band = scripts.get(0);
+ assertEquals("Band", band.getName());
+ assertEquals("1.0.0", band.getVersion());
+ assertEquals(1, band.getFormat());
+ assertEquals("code", band.getSource());
+ assertFalse(band.isDeprecated());
+ DerivedOutputMetaData output = band.getOutput();
+ assertEquals("A band.", output.getDescription());
+ assertEquals("string", output.getValueType());
+ assertSame(String.class, output.getValueClass());
+ assertFalse(output.isList());
+ assertNull(output.getDefaultValue());
+ assertNull(output.getCategory());
+ assertEquals(
+ Arrays.asList("device.BrowserReleaseAge",
+ "device.IsCrawler"),
+ output.getDependencies());
+ List names = new ArrayList<>();
+ List descriptions = new ArrayList<>();
+ for (DerivedValueMetaData value : output.getValues()) {
+ names.add(value.getName());
+ descriptions.add(value.getDescription());
+ }
+ assertEquals(Arrays.asList("Old", "New"), names);
+ assertEquals(Arrays.asList(null, "Recent."), descriptions);
+ assertNull(scripts.get(1).getOutput().getValues());
+ }
+
+ /**
+ * A built-in script's source is its name.
+ */
+ @Test
+ public void builtInScriptSourceIsItsName() {
+ DerivedPropertyElement element = builder()
+ .addScript(BuiltInScript.IsGdpr)
+ .build();
+ assertEquals("IsGdpr", element.getScripts().get(0).getSource());
+ }
+
+ /**
+ * The build logs one information line per script, the compiled model
+ * at debug level and a warning carrying a deprecated script's note.
+ */
+ @Test
+ public void buildLogs() {
+ RecordingLoggerFactory logs = new RecordingLoggerFactory(Level.DEBUG);
+ String deprecated = BAND_YAML.replace("Version: 1.0.0\n",
+ "Version: 1.0.0\nDeprecated: true\n" +
+ "DeprecationNote: Use Likely.\n");
+ new DerivedPropertyElementBuilder(logs)
+ .addScript("Likely", LIKELY_YAML)
+ .addScript("Band", deprecated)
+ .build();
+ List info = logs.messages(Level.INFO, "Derived property");
+ assertEquals(2, info.size(), info.toString());
+ assertEquals("Derived property script 'Likely' version 1.0.0, " +
+ "format 1, from code, produces 'derived.Likely'", info.get(0));
+ List debug = logs.messages(Level.DEBUG, "Derived property");
+ assertEquals(2, debug.size(), debug.toString());
+ assertTrue(debug.get(0).contains("\"Name\": \"Likely\""),
+ debug.get(0));
+ assertTrue(debug.get(0).contains(
+ "\"ip.HumanProbability\": {\n \"Type\": \"int\"\n }"),
+ debug.get(0));
+ List warnings = logs.messages(Level.WARN, "Derived property");
+ assertEquals(1, warnings.size(), warnings.toString());
+ assertEquals("Derived property script 'Band' is deprecated. " +
+ "Use Likely.", warnings.get(0));
+ }
+
+ /**
+ * The compiled model is printed only where debug is on.
+ */
+ @Test
+ public void canonicalJsonIsNotWrittenWithoutDebug() {
+ RecordingLoggerFactory logs = new RecordingLoggerFactory(Level.INFO);
+ new DerivedPropertyElementBuilder(logs)
+ .addScript("Likely", LIKELY_YAML)
+ .build();
+ assertTrue(logs.messages(Level.DEBUG).isEmpty());
+ assertEquals(1, logs.messages(Level.INFO, "Derived property").size());
+ }
+
+ /**
+ * The Element Data gives a value of the type the script produces, and
+ * refuses another type or a property no script produces.
+ */
+ @Test
+ public void elementDataGetAs() throws Exception {
+ DerivedPropertyElement element = likely();
+ try (Pipeline pipeline = buildWithStubs(element, likelyValues());
+ FlowData data = pipeline.createFlowData()) {
+ data.process();
+ IDerivedPropertyData derived = data.get(IDerivedPropertyData.class);
+ assertEquals(Boolean.TRUE,
+ derived.getAs("likely", Boolean.class).getValue());
+ assertThrows(ClassCastException.class,
+ () -> derived.getAs("Likely", String.class));
+ assertThrows(PropertyMissingException.class,
+ () -> derived.getAs("Unlikely", Boolean.class));
+ }
+ }
+
+ /**
+ * Reading a value with no value raises the existing no value error,
+ * carrying the message.
+ */
+ @Test
+ public void readingNoValueThrowsWithTheMessage() throws Exception {
+ Map> values = likelyValues();
+ values.get("device").put("IsCrawler", "N/A");
+ AspectPropertyValue> value =
+ SharedCasesTests.process(likely(), values, "Likely");
+ NoValueException e =
+ assertThrows(NoValueException.class, value::getValue);
+ assertEquals("Derived property 'Likely' has no value because 1 " +
+ "source property was not available. 'device.IsCrawler' (held " +
+ "'N/A' which cannot be read as bool). " +
+ DerivedPropertyElement.USUAL_CAUSES, e.getMessage());
+ }
+
+ static Pipeline buildWithStubs(
+ DerivedPropertyElement element,
+ Map> values) throws Exception {
+ TestPipelineBuilder builder = new TestPipelineBuilder();
+ for (StubSourceElement stub :
+ SharedCasesTests.stubsFor(element, values)) {
+ builder.addFlowElement(stub);
+ }
+ return builder.addFlowElement(element).build();
+ }
+}
diff --git a/pipeline/pipeline.derivedproperty/src/test/java/fiftyone/pipeline/derivedproperty/flowelements/DerivedScriptParserTests.java b/pipeline/pipeline.derivedproperty/src/test/java/fiftyone/pipeline/derivedproperty/flowelements/DerivedScriptParserTests.java
new file mode 100644
index 000000000..5e73f77da
--- /dev/null
+++ b/pipeline/pipeline.derivedproperty/src/test/java/fiftyone/pipeline/derivedproperty/flowelements/DerivedScriptParserTests.java
@@ -0,0 +1,216 @@
+/* *********************************************************************
+ * This Original Work is copyright of 51 Degrees Mobile Experts Limited.
+ * Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House,
+ * Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU.
+ *
+ * This Original Work is licensed under the European Union Public Licence
+ * (EUPL) v.1.2 and is subject to its terms as set out below.
+ *
+ * If a copy of the EUPL was not distributed with this file, You can obtain
+ * one at https://opensource.org/licenses/EUPL-1.2.
+ *
+ * The 'Compatible Licences' set out in the Appendix to the EUPL (as may be
+ * amended by the European Commission) shall be deemed incompatible for
+ * the purposes of the Work and the provisions of the compatibility
+ * clause in Article 5 of the EUPL shall not apply.
+ *
+ * If using the Work as, or as part of, a network application, by
+ * including the attribution notice(s) required under Article 5 of the EUPL
+ * in the end user terms of the application under an appropriate heading,
+ * such notice(s) shall fulfill the requirements of that article.
+ * ********************************************************************* */
+
+package fiftyone.pipeline.derivedproperty.flowelements;
+
+import fiftyone.pipeline.derivedproperty.DerivedScriptFault;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * How a script's text is read. Plain scalars follow the YAML 1.2 core
+ * schema, as the reference tools do, quoted scalars are strings, keys are
+ * written once without regard to case, and JSON is read by the same
+ * reader.
+ *
+ * See writing
+ * YAML that reads the way you meant .
+ */
+public class DerivedScriptParserTests {
+
+ private static ScriptNode value(String yaml) throws Exception {
+ return DerivedScriptParser.parse("Value: " + yaml).get("Value");
+ }
+
+ /**
+ * A script comparing device.Flag with the literal given.
+ */
+ private static String flagScript(String literal) {
+ return "Format: 1\nName: Flag\nVersion: \"1.0.0\"\n" +
+ "Output: { Name: Flag, Description: A flag., ValueType: bool, " +
+ "IsList: false }\n" +
+ "Rules:\n" +
+ " - When: { Property: device.Flag, Eq: " + literal + " }\n" +
+ " Then: true\n" +
+ " - Else: false\n";
+ }
+
+ private static ValueType inferred(String literal) {
+ return DerivedPropertyElementTests.mustValidate(flagScript(literal))
+ .getProperties().get(0).getType();
+ }
+
+ @Test
+ public void onlyTrueAndFalseAreBooleans() throws Exception {
+ for (String text : new String[]{"true", "True", "TRUE"}) {
+ assertSame(ScriptNode.Kind.BOOL, value(text).getKind(), text);
+ assertTrue(value(text).getBool(), text);
+ }
+ for (String text : new String[]{"false", "False", "FALSE"}) {
+ assertSame(ScriptNode.Kind.BOOL, value(text).getKind(), text);
+ }
+ for (String text : new String[]{"yes", "no", "on", "off", "y", "n",
+ "NO", "Yes", "tRUE"}) {
+ assertSame(ScriptNode.Kind.STRING, value(text).getKind(), text);
+ assertEquals(text, value(text).getText());
+ }
+ }
+
+ @Test
+ public void yesInfersStringAndTrueInfersBool() {
+ assertSame(ValueType.STRING, inferred("yes"));
+ assertSame(ValueType.STRING, inferred("\"true\""));
+ assertSame(ValueType.STRING, inferred("'false'"));
+ assertSame(ValueType.BOOL, inferred("true"));
+ }
+
+ @Test
+ public void numbersFollowTheCoreSchema() throws Exception {
+ assertEquals(8, value("8").getNumber());
+ assertEquals(-8, value("-8").getNumber());
+ assertEquals(8, value("+8").getNumber());
+ assertEquals(8.5, value("8.5").getNumber());
+ assertEquals(800, value("8e2").getNumber());
+ assertEquals(0.5, value(".5").getNumber());
+ assertEquals(16, value("0x10").getNumber());
+ assertEquals(15, value("0o17").getNumber());
+ assertEquals(Double.POSITIVE_INFINITY, value(".inf").getNumber());
+ assertEquals(Double.NEGATIVE_INFINITY, value("-.Inf").getNumber());
+ assertTrue(Double.isNaN(value(".NaN").getNumber()));
+ for (String text : new String[]{"1_000", "0b101", "1:30", "8.5.1",
+ "0x", "1e", "0o8"}) {
+ assertSame(ScriptNode.Kind.STRING, value(text).getKind(), text);
+ }
+ assertSame(ScriptNode.Kind.STRING, value("\"8\"").getKind());
+ }
+
+ @Test
+ public void wholeNumbersInferIntWhateverTheirForm() {
+ assertSame(ValueType.INT, inferred("8"));
+ assertSame(ValueType.INT, inferred("8.0"));
+ assertSame(ValueType.INT, inferred("8e0"));
+ assertSame(ValueType.DOUBLE, inferred("8.5"));
+ // Outside the range of int a whole number infers double.
+ assertSame(ValueType.DOUBLE, inferred("3000000000"));
+ }
+
+ @Test
+ public void nullForms() throws Exception {
+ for (String text : new String[]{"~", "null", "Null", "NULL", ""}) {
+ assertSame(ScriptNode.Kind.NULL, value(text).getKind(), text);
+ }
+ assertSame(ScriptNode.Kind.STRING, value("'null'").getKind());
+ }
+
+ @Test
+ public void aKeyWrittenTwiceIsRefused() {
+ ScriptParseException exact = assertThrows(ScriptParseException.class,
+ () -> DerivedScriptParser.parse("Name: A\nName: B\n"));
+ assertTrue(exact.getMessage().contains(
+ "the key 'Name' is written twice in one mapping"),
+ exact.getMessage());
+ ScriptParseException cased = assertThrows(ScriptParseException.class,
+ () -> DerivedScriptParser.parse(
+ "Output:\n Name: A\n name: B\n"));
+ assertTrue(cased.getMessage().contains(
+ "the keys 'Name' and 'name' differ only in case"),
+ cased.getMessage());
+ assertEquals(2, cased.getLine());
+ ScriptParseException json = assertThrows(ScriptParseException.class,
+ () -> DerivedScriptParser.parse("{\"Name\": \"A\", \"NAME\": 1}"));
+ assertTrue(json.getMessage().contains("differ only in case"),
+ json.getMessage());
+ }
+
+ @Test
+ public void textThatIsNotAScript() {
+ assertEquals("the script is empty", assertThrows(
+ ScriptParseException.class,
+ () -> DerivedScriptParser.parse("")).getMessage());
+ assertEquals("the script is empty", assertThrows(
+ ScriptParseException.class,
+ () -> DerivedScriptParser.parse("# only a comment\n"))
+ .getMessage());
+ assertEquals("the script must be a mapping of keys to values at the " +
+ "top level", assertThrows(ScriptParseException.class,
+ () -> DerivedScriptParser.parse("- 1\n- 2\n")).getMessage());
+ ScriptParseException yaml = assertThrows(ScriptParseException.class,
+ () -> DerivedScriptParser.parse("Name: A\n Bad: [\n"));
+ assertTrue(yaml.getMessage().startsWith("the text is not valid YAML: "),
+ yaml.getMessage());
+ assertTrue(yaml.getLine() > 0);
+ ScriptParseException json = assertThrows(ScriptParseException.class,
+ () -> DerivedScriptParser.parse(" {\"Name\": \"A\""));
+ assertTrue(json.getMessage().startsWith("the text is not valid JSON: "),
+ json.getMessage());
+ }
+
+ @Test
+ public void anAliasThatRefersToItselfIsRefused() {
+ ScriptParseException e = assertThrows(ScriptParseException.class,
+ () -> DerivedScriptParser.parse("Rules: &a [ *a ]\n"));
+ assertTrue(e.getMessage().startsWith("the text is not valid YAML"),
+ e.getMessage());
+ }
+
+ @Test
+ public void aliasesAreFollowed() throws Exception {
+ ScriptNode document = DerivedScriptParser.parse(
+ "A: &x { Property: device.X, Eq: 1 }\nB: *x\n");
+ assertEquals(1, document.get("B").get("Eq").getNumber());
+ }
+
+ @Test
+ public void faultsCarryTheLineOfTheMapping() {
+ DerivedScriptValidator.Result result = DerivedScriptValidator.validate(
+ DerivedPropertyElementTests.BAND_YAML.replace(
+ "{ Property: device.IsCrawler, Eq: false }",
+ "{ Property: device.IsCrawler, Equals: false }"),
+ "Band", "code");
+ assertNull(result.getScript());
+ assertEquals(1, result.getFaults().size(), result.getFaults()
+ .toString());
+ DerivedScriptFault fault = result.getFaults().get(0);
+ assertEquals("Rules[0].When.All[1]", fault.getPath());
+ assertEquals(15, fault.getLine());
+ assertEquals("Band (code) line 15 at Rules[0].When.All[1]: unknown " +
+ "operator 'Equals', expected one of Eq, Ne, Gt, Ge, Lt, Le, In, " +
+ "NotIn, StartsWith, EndsWith, Contains", fault.toString());
+ }
+
+ @Test
+ public void inAndNotInAreRefusedOnACount() {
+ DerivedScriptValidator.Result result = DerivedScriptValidator.validate(
+ DerivedPropertyElementTests.LIKELY_YAML.replace(
+ "{ Failed: Checks, Eq: 0 }", "{ Failed: Checks, In: 0 }"),
+ "Likely", "code");
+ assertNull(result.getScript());
+ assertTrue(result.getFaults().get(0).getMessage().contains(
+ "operator 'In' is not allowed on a count"),
+ result.getFaults().toString());
+ }
+}
diff --git a/pipeline/pipeline.derivedproperty/src/test/java/fiftyone/pipeline/derivedproperty/flowelements/RecordingLoggerFactory.java b/pipeline/pipeline.derivedproperty/src/test/java/fiftyone/pipeline/derivedproperty/flowelements/RecordingLoggerFactory.java
new file mode 100644
index 000000000..1f60e09e9
--- /dev/null
+++ b/pipeline/pipeline.derivedproperty/src/test/java/fiftyone/pipeline/derivedproperty/flowelements/RecordingLoggerFactory.java
@@ -0,0 +1,147 @@
+/* *********************************************************************
+ * This Original Work is copyright of 51 Degrees Mobile Experts Limited.
+ * Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House,
+ * Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU.
+ *
+ * This Original Work is licensed under the European Union Public Licence
+ * (EUPL) v.1.2 and is subject to its terms as set out below.
+ *
+ * If a copy of the EUPL was not distributed with this file, You can obtain
+ * one at https://opensource.org/licenses/EUPL-1.2.
+ *
+ * The 'Compatible Licences' set out in the Appendix to the EUPL (as may be
+ * amended by the European Commission) shall be deemed incompatible for
+ * the purposes of the Work and the provisions of the compatibility
+ * clause in Article 5 of the EUPL shall not apply.
+ *
+ * If using the Work as, or as part of, a network application, by
+ * including the attribution notice(s) required under Article 5 of the EUPL
+ * in the end user terms of the application under an appropriate heading,
+ * such notice(s) shall fulfill the requirements of that article.
+ * ********************************************************************* */
+
+package fiftyone.pipeline.derivedproperty.flowelements;
+
+import org.slf4j.ILoggerFactory;
+import org.slf4j.Logger;
+import org.slf4j.Marker;
+import org.slf4j.event.Level;
+import org.slf4j.helpers.LegacyAbstractLogger;
+import org.slf4j.helpers.MessageFormatter;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Keeps every message logged at or above a level, so a test can read what
+ * the element logged.
+ */
+final class RecordingLoggerFactory implements ILoggerFactory {
+
+ private final Level threshold;
+ private final List levels = new ArrayList<>();
+ private final List messages = new ArrayList<>();
+
+ RecordingLoggerFactory(Level threshold) {
+ this.threshold = threshold;
+ }
+
+ @Override
+ public Logger getLogger(String name) {
+ return new RecordingLogger(name, this);
+ }
+
+ /**
+ * @param level the level
+ * @return every message logged at exactly the level, in order
+ */
+ synchronized List messages(Level level) {
+ List found = new ArrayList<>();
+ for (int i = 0; i < levels.size(); i++) {
+ if (levels.get(i) == level) {
+ found.add(messages.get(i));
+ }
+ }
+ return found;
+ }
+
+ /**
+ * @param level the level
+ * @param start the text the message starts with
+ * @return every message logged at the level that starts with the text
+ */
+ List messages(Level level, String start) {
+ List found = new ArrayList<>();
+ for (String message : messages(level)) {
+ if (message.startsWith(start)) {
+ found.add(message);
+ }
+ }
+ return found;
+ }
+
+ private boolean enabled(Level level) {
+ return level.toInt() >= threshold.toInt();
+ }
+
+ private synchronized void record(Level level, String message) {
+ levels.add(level);
+ messages.add(message);
+ }
+
+ /**
+ * A logger that hands every message to the factory.
+ */
+ private static final class RecordingLogger extends LegacyAbstractLogger {
+
+ private static final long serialVersionUID = 1L;
+
+ private final transient RecordingLoggerFactory factory;
+
+ RecordingLogger(String name, RecordingLoggerFactory factory) {
+ this.name = name;
+ this.factory = factory;
+ }
+
+ @Override
+ public boolean isTraceEnabled() {
+ return factory.enabled(Level.TRACE);
+ }
+
+ @Override
+ public boolean isDebugEnabled() {
+ return factory.enabled(Level.DEBUG);
+ }
+
+ @Override
+ public boolean isInfoEnabled() {
+ return factory.enabled(Level.INFO);
+ }
+
+ @Override
+ public boolean isWarnEnabled() {
+ return factory.enabled(Level.WARN);
+ }
+
+ @Override
+ public boolean isErrorEnabled() {
+ return factory.enabled(Level.ERROR);
+ }
+
+ @Override
+ protected String getFullyQualifiedCallerName() {
+ return null;
+ }
+
+ @Override
+ protected void handleNormalizedLoggingCall(
+ Level level,
+ Marker marker,
+ String messagePattern,
+ Object[] arguments,
+ Throwable throwable) {
+ factory.record(level,
+ MessageFormatter.basicArrayFormat(messagePattern, arguments));
+ }
+ }
+}
diff --git a/pipeline/pipeline.derivedproperty/src/test/java/fiftyone/pipeline/derivedproperty/flowelements/SharedCasesTests.java b/pipeline/pipeline.derivedproperty/src/test/java/fiftyone/pipeline/derivedproperty/flowelements/SharedCasesTests.java
new file mode 100644
index 000000000..858202621
--- /dev/null
+++ b/pipeline/pipeline.derivedproperty/src/test/java/fiftyone/pipeline/derivedproperty/flowelements/SharedCasesTests.java
@@ -0,0 +1,419 @@
+/* *********************************************************************
+ * This Original Work is copyright of 51 Degrees Mobile Experts Limited.
+ * Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House,
+ * Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU.
+ *
+ * This Original Work is licensed under the European Union Public Licence
+ * (EUPL) v.1.2 and is subject to its terms as set out below.
+ *
+ * If a copy of the EUPL was not distributed with this file, You can obtain
+ * one at https://opensource.org/licenses/EUPL-1.2.
+ *
+ * The 'Compatible Licences' set out in the Appendix to the EUPL (as may be
+ * amended by the European Commission) shall be deemed incompatible for
+ * the purposes of the Work and the provisions of the compatibility
+ * clause in Article 5 of the EUPL shall not apply.
+ *
+ * If using the Work as, or as part of, a network application, by
+ * including the attribution notice(s) required under Article 5 of the EUPL
+ * in the end user terms of the application under an appropriate heading,
+ * such notice(s) shall fulfill the requirements of that article.
+ * ********************************************************************* */
+
+package fiftyone.pipeline.derivedproperty.flowelements;
+
+import fiftyone.pipeline.core.data.FlowData;
+import fiftyone.pipeline.core.data.IWeightedValue;
+import fiftyone.pipeline.core.data.WeightedValue;
+import fiftyone.pipeline.core.flowelements.Pipeline;
+import fiftyone.pipeline.derivedproperty.BuiltInScript;
+import fiftyone.pipeline.derivedproperty.DerivedScriptFault;
+import fiftyone.pipeline.derivedproperty.data.IDerivedPropertyData;
+import fiftyone.pipeline.engines.data.AspectPropertyValue;
+import fiftyone.pipeline.engines.data.AspectPropertyValueDefault;
+import org.junit.jupiter.api.DynamicTest;
+import org.junit.jupiter.api.TestFactory;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.DirectoryStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+/**
+ * Runs the case files of the derived-properties repository, which every
+ * language runs, so the answers and the faults are checked to match case
+ * for case. The repository is the submodule pipeline/derived-properties.
+ *
+ * See writing
+ * and running cases .
+ */
+public class SharedCasesTests {
+
+ /**
+ * The largest raw weighting a weighted value carries.
+ */
+ private static final int MAX_RAW_WEIGHTING = 65535;
+
+ /**
+ * The derived-properties submodule, which a test fails without.
+ */
+ static Path sharedFolder() {
+ Path folder = Paths.get("..", "derived-properties");
+ if (Files.isDirectory(folder.resolve("tests")) == false) {
+ fail("The derived-properties submodule is missing from " +
+ folder.toAbsolutePath() + ". Run 'git submodule update " +
+ "--init pipeline/derived-properties'.");
+ }
+ return folder;
+ }
+
+ /**
+ * The files in a folder whose names end with the text given, in name
+ * order.
+ */
+ static List files(Path folder, String glob) throws IOException {
+ List found = new ArrayList<>();
+ try (DirectoryStream stream =
+ Files.newDirectoryStream(folder, glob)) {
+ for (Path path : stream) {
+ found.add(path);
+ }
+ }
+ found.sort((a, b) -> a.getFileName().toString()
+ .compareTo(b.getFileName().toString()));
+ return found;
+ }
+
+ static String read(Path file) throws IOException {
+ return new String(Files.readAllBytes(file), StandardCharsets.UTF_8);
+ }
+
+ @TestFactory
+ public List sharedCases() throws Exception {
+ List files = files(
+ sharedFolder().resolve("tests"), "*.cases.yaml");
+ assertFalse(files.isEmpty(),
+ "There are no case files in the derived-properties submodule.");
+ List tests = new ArrayList<>();
+ for (Path file : files) {
+ ScriptNode cases = DerivedScriptParser.parse(read(file));
+ String script = cases.get("Script").getText();
+ BuiltInScript builtIn = BuiltInScript.fromName(script);
+ if (builtIn == null) {
+ fail(file + " proves the script " + script + ", which has " +
+ "no BuiltInScript.");
+ }
+ DerivedPropertyElement element =
+ new DerivedPropertyElementBuilder(
+ LoggerFactory.getILoggerFactory())
+ .addScript(builtIn)
+ .build();
+ String output = element.getScripts().get(0).getOutput().getName();
+ for (boolean wrap : new boolean[]{false, true}) {
+ for (ScriptNode c : cases.get("Cases").getItems()) {
+ String name = script + "/" + c.get("Name").getText() +
+ "/wrapped=" + wrap;
+ tests.add(DynamicTest.dynamicTest(name, () ->
+ runCase(element, output, c, wrap)));
+ }
+ }
+ }
+ return tests;
+ }
+
+ private static void runCase(
+ DerivedPropertyElement element,
+ String output,
+ ScriptNode c,
+ boolean wrap) throws Exception {
+ Map> values = new LinkedHashMap<>();
+ ScriptNode properties = c.get("Properties");
+ for (int i = 0; i < properties.getKeys().size(); i++) {
+ String property = properties.getKeys().get(i);
+ int dot = property.indexOf('.');
+ String key = property.substring(0, dot).toLowerCase(Locale.ROOT);
+ Map forKey = values.get(key);
+ if (forKey == null) {
+ forKey = new LinkedHashMap<>();
+ values.put(key, forKey);
+ }
+ forKey.put(property.substring(dot + 1),
+ caseValue(properties.getValues().get(i), wrap));
+ }
+ AspectPropertyValue> got = process(element, values, output);
+ ScriptNode expect = c.get("Expect");
+ ScriptNode value = expect.get("Value");
+ if (value != null) {
+ assertTrue(got.hasValue(), "Expected '" + expectedText(value) +
+ "' but there was no value. " + got.getNoValueMessage());
+ assertEquals(expectedText(value), valueText(got.getValue()));
+ return;
+ }
+ List missing = new ArrayList<>();
+ for (ScriptNode name : expect.get("Missing").getItems()) {
+ missing.add(name.getText());
+ }
+ checkMissing(got, missing, output);
+ }
+
+ /**
+ * Run one request through stubs holding the values and the element,
+ * and give what the element wrote for the output property.
+ */
+ static AspectPropertyValue> process(
+ DerivedPropertyElement element,
+ Map> values,
+ String output) throws Exception {
+ TestPipelineBuilder builder = new TestPipelineBuilder();
+ for (StubSourceElement stub : stubsFor(element, values)) {
+ builder.addFlowElement(stub);
+ }
+ try (Pipeline pipeline = builder.addFlowElement(element).build();
+ FlowData data = pipeline.createFlowData()) {
+ data.process();
+ Object value = data.get(IDerivedPropertyData.class).get(output);
+ assertTrue(value instanceof AspectPropertyValue>,
+ output + " is " + value + ", not an AspectPropertyValue");
+ return (AspectPropertyValue>) value;
+ }
+ }
+
+ /**
+ * A stub for each key the element reads, listing every property read
+ * under it and holding the values given for it.
+ */
+ static List stubsFor(
+ DerivedPropertyElement element,
+ Map