From 588b240b1ff8983a39d2cdf0bd86d8b1ba609c57 Mon Sep 17 00:00:00 2001 From: Andres Felder <81707831+andyfelder16@users.noreply.github.com> Date: Tue, 21 Jul 2026 09:25:47 -0300 Subject: [PATCH 1/3] score cypher match queries against a graph with a truthness-based heuristic --- .../java/controller/neo4j/data/Neo4jEdge.java | 63 +++ .../controller/neo4j/data/Neo4jGraph.java | 49 ++ .../java/controller/neo4j/data/Neo4jNode.java | 59 +++ .../heuristics/Neo4jConditionEvaluator.java | 419 ++++++++++++++++++ .../heuristics/Neo4jHeuristicsCalculator.java | 190 ++++++++ .../neo4j/heuristics/Neo4jMapping.java | 68 +++ .../heuristics/Neo4jStructuralMatcher.java | 134 ++++++ .../Neo4jHeuristicsCalculatorTest.java | 203 +++++++++ 8 files changed, 1185 insertions(+) create mode 100644 client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/data/Neo4jEdge.java create mode 100644 client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/data/Neo4jGraph.java create mode 100644 client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/data/Neo4jNode.java create mode 100644 client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/heuristics/Neo4jConditionEvaluator.java create mode 100644 client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/heuristics/Neo4jHeuristicsCalculator.java create mode 100644 client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/heuristics/Neo4jMapping.java create mode 100644 client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/heuristics/Neo4jStructuralMatcher.java create mode 100644 client-java/controller/src/test/java/org/evomaster/client/java/controller/neo4j/heuristics/Neo4jHeuristicsCalculatorTest.java diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/data/Neo4jEdge.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/data/Neo4jEdge.java new file mode 100644 index 0000000000..feaee6feec --- /dev/null +++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/data/Neo4jEdge.java @@ -0,0 +1,63 @@ +package org.evomaster.client.java.controller.neo4j.data; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +/** + * An edge (relationship) of a captured Neo4j graph: a stable id, a single type, the ids of its two + * endpoint nodes (source and target), and its property map. A relationship in Neo4j is always stored + * with a direction (source → target). + */ +public class Neo4jEdge { + + private final String id; + private final String type; + private final String sourceId; + private final String targetId; + private final Map properties; + + public Neo4jEdge(String id, String type, String sourceId, String targetId, + Map properties) { + this.id = Objects.requireNonNull(id, "id must not be null"); + this.type = Objects.requireNonNull(type, "type must not be null"); + this.sourceId = Objects.requireNonNull(sourceId, "sourceId must not be null"); + this.targetId = Objects.requireNonNull(targetId, "targetId must not be null"); + this.properties = properties != null ? new LinkedHashMap<>(properties) : new LinkedHashMap<>(); + } + + public String getId() { + return id; + } + + public String getType() { + return type; + } + + public String getSourceId() { + return sourceId; + } + + public String getTargetId() { + return targetId; + } + + public Map getProperties() { + return Collections.unmodifiableMap(properties); + } + + public boolean hasProperty(String key) { + return properties.containsKey(key); + } + + public Object getProperty(String key) { + return properties.get(key); + } + + @Override + public String toString() { + return "Neo4jEdge{" + id + ", type=" + type + ", " + sourceId + "->" + targetId + + ", props=" + properties + "}"; + } +} diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/data/Neo4jGraph.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/data/Neo4jGraph.java new file mode 100644 index 0000000000..e0297ce46e --- /dev/null +++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/data/Neo4jGraph.java @@ -0,0 +1,49 @@ +package org.evomaster.client.java.controller.neo4j.data; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * An in-memory snapshot of a Neo4j graph ({@code G}): the set of nodes and relationships against + * which a parsed query is scored. Built either by hand in tests or by reading the live database + * through the driver. Nodes are indexed by id so a relationship's endpoints can be resolved quickly. + */ +public class Neo4jGraph { + + private final List nodes; + private final List edges; + private final Map nodesById; + + public Neo4jGraph(List nodes, List edges) { + this.nodes = nodes != null ? new ArrayList<>(nodes) : new ArrayList<>(); + this.edges = edges != null ? new ArrayList<>(edges) : new ArrayList<>(); + this.nodesById = new LinkedHashMap<>(); + for (Neo4jNode n : this.nodes) { + nodesById.put(n.getId(), n); + } + } + + public List getNodes() { + return Collections.unmodifiableList(nodes); + } + + public List getEdges() { + return Collections.unmodifiableList(edges); + } + + public int nodeCount() { + return nodes.size(); + } + + public Neo4jNode getNodeById(String id) { + return nodesById.get(id); + } + + @Override + public String toString() { + return "Neo4jGraph{nodes=" + nodes.size() + ", edges=" + edges.size() + "}"; + } +} diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/data/Neo4jNode.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/data/Neo4jNode.java new file mode 100644 index 0000000000..d08872b169 --- /dev/null +++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/data/Neo4jNode.java @@ -0,0 +1,59 @@ +package org.evomaster.client.java.controller.neo4j.data; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * A node of a captured Neo4j graph, used as the {@code G} side when computing the heuristic + * {@code H(Q, G)}. It is the in-memory counterpart of a stored node: a stable id (the driver's + * {@code elementId}), the set of labels, and the property map. + *

+ * This is a plain data holder built either by hand (tests) or by reading the live database; it has + * no relation to {@link org.evomaster.client.java.controller.neo4j.operations.PatternNode}, which is + * the structural node of a parsed query pattern. + */ +public class Neo4jNode { + + private final String id; + private final Set labels; + private final Map properties; + + public Neo4jNode(String id, Set labels, Map properties) { + this.id = Objects.requireNonNull(id, "id must not be null"); + this.labels = labels != null ? new LinkedHashSet<>(labels) : new LinkedHashSet<>(); + this.properties = properties != null ? new LinkedHashMap<>(properties) : new LinkedHashMap<>(); + } + + public String getId() { + return id; + } + + public Set getLabels() { + return Collections.unmodifiableSet(labels); + } + + public Map getProperties() { + return Collections.unmodifiableMap(properties); + } + + /** + * Returns true when the property is present on this node. Distinguishes an absent property + * (the operand cannot be valuated) from a present property whose value is {@code null}. + */ + public boolean hasProperty(String key) { + return properties.containsKey(key); + } + + public Object getProperty(String key) { + return properties.get(key); + } + + @Override + public String toString() { + return "Neo4jNode{" + id + ", labels=" + labels + ", props=" + properties + "}"; + } +} diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/heuristics/Neo4jConditionEvaluator.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/heuristics/Neo4jConditionEvaluator.java new file mode 100644 index 0000000000..d8968431e3 --- /dev/null +++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/heuristics/Neo4jConditionEvaluator.java @@ -0,0 +1,419 @@ +package org.evomaster.client.java.controller.neo4j.heuristics; + +import org.evomaster.client.java.controller.neo4j.conditions.*; +import org.evomaster.client.java.controller.neo4j.data.Neo4jEdge; +import org.evomaster.client.java.controller.neo4j.data.Neo4jNode; +import org.evomaster.client.java.distance.heuristics.DistanceHelper; +import org.evomaster.client.java.distance.heuristics.Truthness; +import org.evomaster.client.java.distance.heuristics.TruthnessUtils; +import org.evomaster.client.java.sql.internal.TaintHandler; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +import static org.evomaster.client.java.controller.neo4j.heuristics.Neo4jHeuristicsCalculator.C; +import static org.evomaster.client.java.controller.neo4j.heuristics.Neo4jHeuristicsCalculator.FALSE_TRUTHNESS; +import static org.evomaster.client.java.controller.neo4j.heuristics.Neo4jHeuristicsCalculator.TRUE_TRUTHNESS; + +/** + * Evaluates the truthness {@code ρ(condition, m)} of a single {@link CypherCondition} under a + * structural mapping {@code m}, recursively over the typed boolean tree the parser produces + * (And/Or/Xor/Not, comparisons, label/type/property leaves). + *

+ * Returns {@code null} when a condition cannot be evaluated under the mapping (e.g. an absent + * property, an unbound variable, or an opaque/raw operand); the caller's aggregation skips + * {@code null} results. + */ +class Neo4jConditionEvaluator { + + private static final Object UNRESOLVED = new Object(); + + private final TaintHandler taintHandler; + + Neo4jConditionEvaluator() { + this(null); + } + + Neo4jConditionEvaluator(TaintHandler taintHandler) { + this.taintHandler = taintHandler; + } + + /** + * Returns {@code ρ(condition, mapping)}, or {@code null} when the condition cannot be evaluated + * and must be skipped by the aggregation. + */ + Truthness evaluateCondition(CypherCondition condition, Neo4jMapping mapping) { + if (condition instanceof LabelCondition) { + LabelCondition lc = (LabelCondition) condition; + Neo4jNode node = mapping.getNode(lc.getVariableName()); + return node == null ? null : labelInSet(lc.getLabel(), node.getLabels()); + } + if (condition instanceof AnyLabelCondition) { + Neo4jNode node = mapping.getNode(((AnyLabelCondition) condition).getVariableName()); + if (node == null) { + return null; + } + return node.getLabels().isEmpty() ? FALSE_TRUTHNESS : TRUE_TRUTHNESS; + } + if (condition instanceof TypeCondition) { + TypeCondition tc = (TypeCondition) condition; + Neo4jEdge rel = mapping.getEdge(tc.getVariableName()); + if (rel == null) { + return null; + } + taintStringEquals(rel.getType(), tc.getType()); + return stringEqualityTruthness(rel.getType(), tc.getType()); + } + if (condition instanceof PropertyCondition) { + return evaluateProperty((PropertyCondition) condition, mapping); + } + if (condition instanceof ComparisonCondition) { + return evaluateComparison((ComparisonCondition) condition, mapping); + } + if (condition instanceof AndCondition) { + return aggregate(((AndCondition) condition).getConditions(), mapping, true); + } + if (condition instanceof OrCondition) { + return aggregate(((OrCondition) condition).getConditions(), mapping, false); + } + if (condition instanceof XorCondition) { + return evaluateXor(((XorCondition) condition).getConditions(), mapping); + } + if (condition instanceof NotCondition) { + Truthness inner = evaluateCondition(((NotCondition) condition).getCondition(), mapping); + return inner == null ? null : inner.invert(); + } + return null; + } + + private Truthness evaluateProperty(PropertyCondition pc, Neo4jMapping mapping) { + Object actual = resolveProperty(pc.getVariableName(), pc.getPropertyKey(), mapping); + if (actual == UNRESOLVED) { + return null; + } + Object expected = resolveOperandValue(pc.getValue(), mapping); + if (expected == UNRESOLVED) { + return null; + } + return equalityTruthness(actual, expected); + } + + private Truthness evaluateComparison(ComparisonCondition cc, Neo4jMapping mapping) { + switch (cc.getOperator()) { + case IS_NULL: + return presenceTruthness(cc.getLeft(), mapping, /*wantPresent=*/false); + case IS_NOT_NULL: + return presenceTruthness(cc.getLeft(), mapping, /*wantPresent=*/true); + case IN: + return evaluateIn(cc, mapping); + case STARTS_WITH: + case ENDS_WITH: + case CONTAINS: + return evaluateStringPredicate(cc, mapping); + default: + return evaluateBinaryComparison(cc, mapping); + } + } + + private Truthness evaluateBinaryComparison(ComparisonCondition cc, Neo4jMapping mapping) { + Object l = resolveOperandValue(cc.getLeft(), mapping); + Object r = resolveOperandValue(cc.getRight(), mapping); + if (l == UNRESOLVED || r == UNRESOLVED || l == null || r == null) { + return null; + } + switch (cc.getOperator()) { + case EQUALS: + return equalityTruthness(l, r); + case NOT_EQUALS: + return equalityTruthness(l, r).invert(); + case LESS_THAN: + return numericLessThan(l, r); + case GREATER_THAN: + return numericLessThan(r, l); + case LESS_THAN_OR_EQUALS: { + Truthness t = numericLessThan(r, l); + return t == null ? null : t.invert(); + } + case GREATER_THAN_OR_EQUALS: { + Truthness t = numericLessThan(l, r); + return t == null ? null : t.invert(); + } + default: + return null; + } + } + + private Truthness evaluateIn(ComparisonCondition cc, Neo4jMapping mapping) { + Object l = resolveOperandValue(cc.getLeft(), mapping); + if (l == UNRESOLVED || l == null || !(cc.getRight() instanceof ListOperand)) { + return null; + } + List elements = ((ListOperand) cc.getRight()).getElements(); + List truths = new ArrayList<>(); + for (Operand element : elements) { + Object ev = resolveOperandValue(element, mapping); + if (ev == UNRESOLVED || ev == null) { + continue; + } + truths.add(equalityTruthness(l, ev)); + } + if (truths.isEmpty()) { + return null; + } + return TruthnessUtils.buildOrAggregationTruthness(truths.toArray(new Truthness[0])); + } + + private Truthness evaluateStringPredicate(ComparisonCondition cc, Neo4jMapping mapping) { + Object l = resolveOperandValue(cc.getLeft(), mapping); + Object r = resolveOperandValue(cc.getRight(), mapping); + if (!(l instanceof String) || !(r instanceof String)) { + return null; + } + switch (cc.getOperator()) { + case STARTS_WITH: + return getStartsWith((String) l, (String) r); + case ENDS_WITH: + return getEndsWith((String) l, (String) r); + case CONTAINS: + return getContains((String) l, (String) r); + default: + return null; + } + } + + private Truthness evaluateXor(List conditions, Neo4jMapping mapping) { + Truthness acc = null; + for (CypherCondition c : conditions) { + Truthness t = evaluateCondition(c, mapping); + if (t == null) { + continue; + } + acc = (acc == null) ? t : TruthnessUtils.buildXorAggregationTruthness(acc, t); + } + return acc; + } + + /** AND/OR aggregation over a child list, skipping children that cannot be evaluated. */ + private Truthness aggregate(List conditions, Neo4jMapping mapping, boolean and) { + List truths = new ArrayList<>(); + for (CypherCondition c : conditions) { + Truthness t = evaluateCondition(c, mapping); + if (t != null) { + truths.add(t); + } + } + if (truths.isEmpty()) { + return null; + } + Truthness[] arr = truths.toArray(new Truthness[0]); + return and ? TruthnessUtils.buildAndAggregationTruthness(arr) + : TruthnessUtils.buildOrAggregationTruthness(arr); + } + + /** + * Resolves an operand's value ({@code v(x)} in {@code Formalizing.md}) under the mapping. Returns + * {@link #UNRESOLVED} for an absent property, an opaque {@link RawOperand}, a list (handled only + * inside IN), an unbound variable, or an arithmetic expression over a non-numeric / unresolved side. + */ + private Object resolveOperandValue(Operand operand, Neo4jMapping mapping) { + if (operand instanceof LiteralOperand) { + return ((LiteralOperand) operand).getValue(); + } + if (operand instanceof PropertyOperand) { + PropertyOperand po = (PropertyOperand) operand; + return resolveProperty(po.getVariableName(), po.getPropertyKey(), mapping); + } + if (operand instanceof ArithmeticOperand) { + return resolveArithmeticOperandValue((ArithmeticOperand) operand, mapping); + } + return UNRESOLVED; + } + + private Object resolveArithmeticOperandValue(ArithmeticOperand ao, Neo4jMapping mapping) { + if (ao.getOperator() == ArithmeticOperator.NEGATE) { + Object v = resolveOperandValue(ao.getLeft(), mapping); + Double d = asDouble(v); + return d == null ? UNRESOLVED : -d; + } + Double l = asDouble(resolveOperandValue(ao.getLeft(), mapping)); + Double r = asDouble(resolveOperandValue(ao.getRight(), mapping)); + if (l == null || r == null) { + return UNRESOLVED; + } + switch (ao.getOperator()) { + case PLUS: return l + r; + case MINUS: return l - r; + case TIMES: return l * r; + case DIVIDE: return r == 0d ? UNRESOLVED : l / r; + case MODULO: return r == 0d ? UNRESOLVED : l % r; + case POWER: return Math.pow(l, r); + default: return UNRESOLVED; + } + } + + /** Resolves {@code variable.key} from the node or relationship the variable is bound to. */ + private Object resolveProperty(String variable, String key, Neo4jMapping mapping) { + Neo4jNode node = mapping.getNode(variable); + if (node != null) { + return node.hasProperty(key) ? node.getProperty(key) : UNRESOLVED; + } + Neo4jEdge rel = mapping.getEdge(variable); + if (rel != null) { + return rel.hasProperty(key) ? rel.getProperty(key) : UNRESOLVED; + } + return UNRESOLVED; + } + + /** ρ for IS NULL / IS NOT NULL: a presence check with no gradient. */ + private Truthness presenceTruthness(Operand operand, Neo4jMapping mapping, boolean wantPresent) { + if (!(operand instanceof PropertyOperand)) { + return null; + } + PropertyOperand po = (PropertyOperand) operand; + Object value = resolveProperty(po.getVariableName(), po.getPropertyKey(), mapping); + boolean present = value != UNRESOLVED && value != null; + boolean satisfied = wantPresent == present; + return satisfied ? TRUE_TRUTHNESS : FALSE_TRUTHNESS; + } + + private Truthness equalityTruthness(Object a, Object b) { + if (a == null || b == null) { + return null; + } + Double da = asDouble(a); + Double db = asDouble(b); + if (da != null && db != null) { + return TruthnessUtils.getEqualityTruthness((double) da, (double) db); + } + if (a instanceof String && b instanceof String) { + taintStringEquals((String) a, (String) b); + return stringEqualityTruthness((String) a, (String) b); + } + return a.equals(b) ? TRUE_TRUTHNESS : FALSE_TRUTHNESS; + } + + /** Feeds a string equality to the taint handler (no-op if there is no handler or no tainted input). */ + private void taintStringEquals(String a, String b) { + if (taintHandler != null && a != null && b != null) { + taintHandler.handleTaintForStringEquals(a, b, false); + } + } + + private Truthness numericLessThan(Object a, Object b) { + Double da = asDouble(a); + Double db = asDouble(b); + if (da == null || db == null) { + return null; + } + return TruthnessUtils.getLessThanTruthness((double) da, (double) db); + } + + /** + * Direct string equality truthness: {@code TRUE} when the strings are equal, otherwise {@code ofTrue} + * is the left-alignment edit distance scaled from base {@code C} (so it never drops below {@code C}) + * and {@code ofFalse = 1}. Used where a string is compared for equality on its own — relationship + * types and string-valued property/WHERE equality. + */ + static Truthness stringEqualityTruthness(String a, String b) { + if (a == null || b == null) { + return null; + } + if (a.equals(b)) { + return TRUE_TRUTHNESS; + } + double distance = DistanceHelper.getLeftAlignmentDistance(a, b); + double h = DistanceHelper.heuristicFromScaledDistanceWithBase(C, distance); + return new Truthness(h, 1d); + } + + /** + * Un-based string similarity ({@code ofTrue = 1 - normalize(distance)}), used only as the per-label + * score inside {@link #labelInSet}, which then re-bases the best of them with {@code scaleTrue(C, ...)}. + * Kept separate from {@link #stringEqualityTruthness} so the base {@code C} is applied exactly once + * on the label path (applying it here as well would double-count it). + */ + static Truthness stringSimilarityTruthness(String a, String b) { + if (a == null || b == null) { + return null; + } + long distance = DistanceHelper.getLeftAlignmentDistance(a, b); + double ofTrue = 1d - TruthnessUtils.normalizeValue((double) distance); + return new Truthness(ofTrue, a.equals(b) ? 0d : 1d); + } + + /** + * {@code label_in_set(L, labels)}: TRUE if the exact label is present; FALSE if the element has + * no labels; otherwise the best per-label string similarity scaled from base {@code C}. + */ + static Truthness labelInSet(String label, Set labels) { + if (labels.contains(label)) { + return TRUE_TRUTHNESS; + } + if (labels.isEmpty()) { + return FALSE_TRUTHNESS; + } + double maxOfTrue = 0d; + for (String present : labels) { + Truthness t = stringSimilarityTruthness(label, present); + if (t.getOfTrue() > maxOfTrue) { + maxOfTrue = t.getOfTrue(); + } + } + return TruthnessUtils.buildScaledTruthness(C, maxOfTrue); + } + + private static Double asDouble(Object value) { + if (value instanceof Number) { + return ((Number) value).doubleValue(); + } + return null; + } + + static Truthness getStartsWith(String str, String prefix) { + if (str.startsWith(prefix)) { + return TRUE_TRUTHNESS; + } + int n = Math.min(str.length(), prefix.length()); + String actualPrefix = str.substring(0, n); + double distance = DistanceHelper.getLeftAlignmentDistance(actualPrefix, prefix); + double h = DistanceHelper.heuristicFromScaledDistanceWithBase(C, distance); + return new Truthness(h, 1d); + } + + static Truthness getEndsWith(String str, String suffix) { + if (str.endsWith(suffix)) { + return TRUE_TRUTHNESS; + } + int n = Math.min(str.length(), suffix.length()); + String actualSuffix = str.substring(str.length() - n); + String expectedSuffix = suffix.substring(suffix.length() - n); + double distance = DistanceHelper.getLeftAlignmentDistance(actualSuffix, expectedSuffix); + double h = DistanceHelper.heuristicFromScaledDistanceWithBase(C, distance); + return new Truthness(h, 1d); + } + + static Truthness getContains(String str, String substring) { + if (str.contains(substring)) { + return TRUE_TRUTHNESS; + } + double distance = getMinSubstringDistance(str, substring); + double h = DistanceHelper.heuristicFromScaledDistanceWithBase(C, distance); + return new Truthness(h, 1d); + } + + private static double getMinSubstringDistance(String str, String substring) { + if (str.length() < substring.length()) { + return DistanceHelper.getLeftAlignmentDistance(str, substring); + } + double minDistance = Double.MAX_VALUE; + for (int i = 0; i <= str.length() - substring.length(); i++) { + String window = str.substring(i, i + substring.length()); + double distance = DistanceHelper.getLeftAlignmentDistance(window, substring); + if (distance < minDistance) { + minDistance = distance; + } + } + return minDistance; + } +} diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/heuristics/Neo4jHeuristicsCalculator.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/heuristics/Neo4jHeuristicsCalculator.java new file mode 100644 index 0000000000..b1c1d9a6ba --- /dev/null +++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/heuristics/Neo4jHeuristicsCalculator.java @@ -0,0 +1,190 @@ +package org.evomaster.client.java.controller.neo4j.heuristics; + +import org.evomaster.client.java.controller.neo4j.conditions.CypherCondition; +import org.evomaster.client.java.controller.neo4j.data.Neo4jEdge; +import org.evomaster.client.java.controller.neo4j.data.Neo4jGraph; +import org.evomaster.client.java.controller.neo4j.data.Neo4jNode; +import org.evomaster.client.java.controller.neo4j.operations.MatchOperation; +import org.evomaster.client.java.controller.neo4j.operations.MatchPattern; +import org.evomaster.client.java.controller.neo4j.operations.PatternEdge; +import org.evomaster.client.java.distance.heuristics.Truthness; +import org.evomaster.client.java.distance.heuristics.TruthnessUtils; +import org.evomaster.client.java.sql.internal.TaintHandler; + +import java.util.ArrayList; +import java.util.List; + +/** + * Computes the search heuristic {@code H(Q, G)} of a parsed Cypher MATCH query {@code Q} against a + * captured graph {@code G}, returning a {@link Truthness} (how close {@code G} is to satisfying + * {@code Q}). It works in {@link Truthness} end-to-end, reusing the shared {@link TruthnessUtils} + * primitives for the aggregations. + *

+ * {@code H(Q, G) = andAggregation(H_match(P_s, G), H_where(C_all, matched_elements(P_s, G)))}, where + * {@code P_s} is the structural pattern and {@code C_all} the conditions. + *

+ * Expects {@code query} to already be in canonical form: no quantified path patterns and no + * variable-length edges (both are expanded to a plain pattern by a separate canonization step before + * reaching this calculator). + */ +public class Neo4jHeuristicsCalculator { + + public static final double C = 0.1; + public static final Truthness TRUE_TRUTHNESS = new Truthness(1, C); + public static final Truthness FALSE_TRUTHNESS = new Truthness(C, 1); + + private final Neo4jStructuralMatcher matcher = new Neo4jStructuralMatcher(); + private final Neo4jConditionEvaluator evaluator; + + public Neo4jHeuristicsCalculator() { + this(null); + } + + public Neo4jHeuristicsCalculator(TaintHandler taintHandler) { + this.evaluator = new Neo4jConditionEvaluator(taintHandler); + } + + Truthness computeHeuristic(MatchOperation query, Neo4jGraph graph) { + MatchPattern pattern = query.getPattern(); + List conditions = query.getConditions(); + + List mappings = matcher.matchedElements(pattern, graph); + + if (query.isOptional() && mappings.isEmpty()) { + return TRUE_TRUTHNESS; + } + + Truthness hMatch = computeHeuristicPattern(pattern, graph, mappings); + Truthness hWhere = computeHeuristicWhere(conditions, mappings); + return TruthnessUtils.buildAndAggregationTruthness(hMatch, hWhere); + } + + /** + * Converts a heuristic to the distance form: {@code 1 - ofTrue}, in + * {@code [0,1]}, where 0 means the query is satisfied. + */ + public double computeDistance(MatchOperation query, Neo4jGraph graph) { + return 1.0d - computeHeuristic(query, graph).getOfTrue(); + } + + private Truthness computeHeuristicPattern(MatchPattern pattern, Neo4jGraph graph, List mappings) { + Truthness nodes = computeHeuristicMatchNodes(pattern.nodeCount(), graph.nodeCount()); + Truthness edges = computeHeuristicMatchEdges(pattern.getEdges(), graph, mappings); + return TruthnessUtils.buildAndAggregationTruthness(nodes, edges); + } + + /** + * Count-based node availability: enough graph nodes to bind the pattern's nodes. Pure cardinality, + * no label/property check (those are conditions evaluated by H_where). + */ + Truthness computeHeuristicMatchNodes(int required, int available) { + if (required == 0) { + return TRUE_TRUTHNESS; + } + if (available == 0) { + return FALSE_TRUTHNESS; + } + if (available >= required) { + return TRUE_TRUTHNESS; + } + return TruthnessUtils.buildScaledTruthness(C, (double) available / required); + } + + /** + * Edge availability: the best, over all node mappings, of whether every pattern edge has a + * matching graph relationship under that mapping. Empty edge set is vacuously satisfied. + */ + private Truthness computeHeuristicMatchEdges(List patternEdges, Neo4jGraph graph, + List mappings) { + if (mappings.isEmpty()) { + return FALSE_TRUTHNESS; + } + if (patternEdges.isEmpty()) { + return TRUE_TRUTHNESS; + } + // matched_elements already binds every pattern edge to an existing relationship, so edgeMatch is + // TRUE for any mapping here; the per-mapping scaled form is kept for the general case and stays + // correct should the matcher ever yield node-only mappings. + double maxOfTrue = 0d; + for (Neo4jMapping mapping : mappings) { + Truthness t = edgesForMapping(patternEdges, graph, mapping); + if (t.isTrue()) { + return TRUE_TRUTHNESS; + } + if (t.getOfTrue() > maxOfTrue) { + maxOfTrue = t.getOfTrue(); + } + } + return TruthnessUtils.buildScaledTruthness(C, maxOfTrue); + } + + private Truthness edgesForMapping(List patternEdges, Neo4jGraph graph, + Neo4jMapping mapping) { + Truthness[] perEdge = new Truthness[patternEdges.size()]; + for (int i = 0; i < patternEdges.size(); i++) { + perEdge[i] = edgeMatch(patternEdges.get(i), graph, mapping); + } + return TruthnessUtils.buildAndAggregationTruthness(perEdge); + } + + /** Existence-only edge check: TRUE if some graph relationship matches the edge's endpoints. */ + private Truthness edgeMatch(PatternEdge edge, Neo4jGraph graph, Neo4jMapping mapping) { + Neo4jNode source = mapping.getNode(edge.getSourceVariable()); + Neo4jNode target = mapping.getNode(edge.getTargetVariable()); + if (source == null || target == null) { + return FALSE_TRUTHNESS; + } + for (Neo4jEdge rel : graph.getEdges()) { + boolean forward = rel.getSourceId().equals(source.getId()) + && rel.getTargetId().equals(target.getId()); + boolean backward = !edge.isDirected() + && rel.getSourceId().equals(target.getId()) + && rel.getTargetId().equals(source.getId()); + if (forward || backward) { + return TRUE_TRUTHNESS; + } + } + return FALSE_TRUTHNESS; + } + + /** + * Best, over all matched elements, of how well the conditions hold; if no mapping satisfies them + * fully, the best partial score scaled from base {@code C}. No mappings means the structure was + * absent, so the conditions cannot hold: FALSE. + */ + private Truthness computeHeuristicWhere(List conditions, List mappings) { + if (mappings.isEmpty()) { + return FALSE_TRUTHNESS; + } + double maxOfTrue = 0d; + for (Neo4jMapping mapping : mappings) { + Truthness t = matchConditions(conditions, mapping); + if (t.isTrue()) { + return TRUE_TRUTHNESS; + } + if (t.getOfTrue() > maxOfTrue) { + maxOfTrue = t.getOfTrue(); + } + } + return TruthnessUtils.buildScaledTruthness(C, maxOfTrue); + } + + /** + * AND-aggregates the truthness of every condition under one mapping. Conditions that cannot be + * valuated (absent property, opaque/raw) are skipped; if none remain, the mapping vacuously + * satisfies the (empty) constraint set. + */ + private Truthness matchConditions(List conditions, Neo4jMapping mapping) { + List truths = new ArrayList<>(); + for (CypherCondition c : conditions) { + Truthness t = evaluator.evaluateCondition(c, mapping); + if (t != null) { + truths.add(t); + } + } + if (truths.isEmpty()) { + return TRUE_TRUTHNESS; + } + return TruthnessUtils.buildAndAggregationTruthness(truths.toArray(new Truthness[0])); + } +} diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/heuristics/Neo4jMapping.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/heuristics/Neo4jMapping.java new file mode 100644 index 0000000000..63487f293d --- /dev/null +++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/heuristics/Neo4jMapping.java @@ -0,0 +1,68 @@ +package org.evomaster.client.java.controller.neo4j.heuristics; + +import org.evomaster.client.java.controller.neo4j.data.Neo4jEdge; +import org.evomaster.client.java.controller.neo4j.data.Neo4jNode; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * A single structural mapping {@code m = (μ, ε)} from a query pattern {@code P_s} onto a graph + * {@code G}: it binds each pattern node variable to a graph node ({@code μ}) and each pattern edge + * variable to a graph relationship ({@code ε}). Bindings are keyed by the variable name the parser + * assigned (a real name like {@code n}, or an anonymous {@code _anon_node_0} / {@code _anon_rel_0}), + * which is shared between the {@code PatternEdge}/{@code PatternNode} and the conditions that refer + * to it, so condition valuation can resolve a variable to its bound graph element. + */ +class Neo4jMapping { + + private final Map nodeBindings; + private final Map edgeBindings; + + Neo4jMapping() { + this.nodeBindings = new LinkedHashMap<>(); + this.edgeBindings = new LinkedHashMap<>(); + } + + private Neo4jMapping(Map nodeBindings, Map edgeBindings) { + this.nodeBindings = new LinkedHashMap<>(nodeBindings); + this.edgeBindings = new LinkedHashMap<>(edgeBindings); + } + + Neo4jMapping copy() { + return new Neo4jMapping(nodeBindings, edgeBindings); + } + + Neo4jNode getNode(String variable) { + return nodeBindings.get(variable); + } + + Neo4jEdge getEdge(String variable) { + return edgeBindings.get(variable); + } + + boolean isNodeBound(String variable) { + return nodeBindings.containsKey(variable); + } + + void bindNode(String variable, Neo4jNode node) { + nodeBindings.put(variable, node); + } + + void bindEdge(String variable, Neo4jEdge edge) { + edgeBindings.put(variable, edge); + } + + /** + * True when this graph edge is already used by some pattern edge in this mapping. Cypher + * enforces relationship uniqueness within a single MATCH, so the enumerator avoids reusing one. + */ + boolean usesEdge(Neo4jEdge edge) { + return edgeBindings.containsValue(edge); + } + + @Override + public String toString() { + return "Neo4jMapping{nodes=" + nodeBindings + ", edges=" + edgeBindings + "}"; + } +} diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/heuristics/Neo4jStructuralMatcher.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/heuristics/Neo4jStructuralMatcher.java new file mode 100644 index 0000000000..7547992d2b --- /dev/null +++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/heuristics/Neo4jStructuralMatcher.java @@ -0,0 +1,134 @@ +package org.evomaster.client.java.controller.neo4j.heuristics; + +import org.evomaster.client.java.controller.neo4j.data.Neo4jEdge; +import org.evomaster.client.java.controller.neo4j.data.Neo4jGraph; +import org.evomaster.client.java.controller.neo4j.data.Neo4jNode; +import org.evomaster.client.java.controller.neo4j.operations.MatchPattern; +import org.evomaster.client.java.controller.neo4j.operations.PatternEdge; +import org.evomaster.client.java.controller.neo4j.operations.PatternNode; +import org.evomaster.client.java.utils.SimpleLogger; + +import java.util.ArrayList; +import java.util.List; + +/** + * Enumerates {@code matched_elements(P_s, G)}: every structurally valid mapping {@code (μ, ε)} of a + * stripped pattern onto a graph. A mapping binds each pattern node variable to a graph node and each + * pattern edge variable to a graph relationship such that the relationship's endpoints agree with the + * node bindings (an undirected edge matches either orientation), repeated variables resolve to the + * same element (equijoins), and no graph relationship is used twice in one mapping. + */ +class Neo4jStructuralMatcher { + + /** + * Upper bound on the number of mappings enumerated, to avoid combinatorial blow-up on large + * graphs / patterns. Once reached, enumeration stops and the partial set is used. + */ + static final int MAX_NUM_MAPPINGS = 2000; + + List matchedElements(MatchPattern pattern, Neo4jGraph graph) { + List results = new ArrayList<>(); + List edges = pattern.getEdges(); + + if (edges.isEmpty()) { + extendIsolatedNodes(pattern.getNodes(), 0, new Neo4jMapping(), graph, results); + } else { + backtrackEdges(edges, 0, new Neo4jMapping(), pattern, graph, results); + } + + if (results.size() >= MAX_NUM_MAPPINGS) { + // The message includes the pattern so uniqueWarn's dedup is keyed per distinct pattern, + // not globally: otherwise the first pattern to ever hit the cap would silently suppress + // the warning for every other, different pattern that hits it later in the same run. + SimpleLogger.uniqueWarn("Neo4j structural matching hit the cap of " + MAX_NUM_MAPPINGS + + " mappings for pattern " + pattern + + "; the heuristic is computed over a partial set of mappings."); + } + return results; + } + + private void backtrackEdges(List edges, int index, Neo4jMapping current, + MatchPattern pattern, Neo4jGraph graph, List results) { + if (results.size() >= MAX_NUM_MAPPINGS) { + return; + } + if (index == edges.size()) { + extendIsolatedNodes(pattern.getNodes(), 0, current, graph, results); + return; + } + + PatternEdge edge = edges.get(index); + for (Neo4jEdge rel : graph.getEdges()) { + if (current.usesEdge(rel)) { + continue; + } + tryOrientation(current, edge, rel, rel.getSourceId(), rel.getTargetId(), + edges, index, pattern, graph, results); + if (!edge.isDirected()) { + tryOrientation(current, edge, rel, rel.getTargetId(), rel.getSourceId(), + edges, index, pattern, graph, results); + } + if (results.size() >= MAX_NUM_MAPPINGS) { + return; + } + } + } + + /** + * Binds {@code edge} to {@code rel} with the given endpoint orientation, if consistent with the + * bindings already in {@code current}, then recurses to the next edge. + */ + private void tryOrientation(Neo4jMapping current, PatternEdge edge, Neo4jEdge rel, + String sourceNodeId, String targetNodeId, + List edges, int index, MatchPattern pattern, + Neo4jGraph graph, List results) { + if (!consistentNode(current, edge.getSourceVariable(), sourceNodeId) + || !consistentNode(current, edge.getTargetVariable(), targetNodeId)) { + return; + } + Neo4jMapping next = current.copy(); + next.bindNode(edge.getSourceVariable(), graph.getNodeById(sourceNodeId)); + next.bindNode(edge.getTargetVariable(), graph.getNodeById(targetNodeId)); + next.bindEdge(edge.getVariableName(), rel); + backtrackEdges(edges, index + 1, next, pattern, graph, results); + } + + private boolean consistentNode(Neo4jMapping current, String variable, String nodeId) { + if (!current.isNodeBound(variable)) { + return true; + } + Neo4jNode bound = current.getNode(variable); + return bound != null && bound.getId().equals(nodeId); + } + + /** + * Cartesian extension over pattern nodes not yet bound by an edge: each ranges over all graph + * nodes. For a fully edge-connected pattern this is a no-op (all nodes already bound). + */ + private void extendIsolatedNodes(List nodes, int index, Neo4jMapping current, + Neo4jGraph graph, List results) { + if (results.size() >= MAX_NUM_MAPPINGS) { + return; + } + if (index == nodes.size()) { + results.add(current.copy()); + return; + } + PatternNode node = nodes.get(index); + if (current.isNodeBound(node.getVariableName())) { + extendIsolatedNodes(nodes, index + 1, current, graph, results); + return; + } + if (graph.getNodes().isEmpty()) { + return; + } + for (Neo4jNode graphNode : graph.getNodes()) { + Neo4jMapping next = current.copy(); + next.bindNode(node.getVariableName(), graphNode); + extendIsolatedNodes(nodes, index + 1, next, graph, results); + if (results.size() >= MAX_NUM_MAPPINGS) { + return; + } + } + } +} diff --git a/client-java/controller/src/test/java/org/evomaster/client/java/controller/neo4j/heuristics/Neo4jHeuristicsCalculatorTest.java b/client-java/controller/src/test/java/org/evomaster/client/java/controller/neo4j/heuristics/Neo4jHeuristicsCalculatorTest.java new file mode 100644 index 0000000000..87279aa61c --- /dev/null +++ b/client-java/controller/src/test/java/org/evomaster/client/java/controller/neo4j/heuristics/Neo4jHeuristicsCalculatorTest.java @@ -0,0 +1,203 @@ +package org.evomaster.client.java.controller.neo4j.heuristics; + +import org.evomaster.client.java.controller.neo4j.data.Neo4jEdge; +import org.evomaster.client.java.controller.neo4j.data.Neo4jGraph; +import org.evomaster.client.java.controller.neo4j.data.Neo4jNode; +import org.evomaster.client.java.controller.neo4j.operations.MatchOperation; +import org.evomaster.client.java.controller.neo4j.parser.CypherParser; +import org.evomaster.client.java.controller.neo4j.parser.CypherParserException; +import org.evomaster.client.java.controller.neo4j.parser.CypherParserFactory; +import org.evomaster.client.java.distance.heuristics.Truthness; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Validates {@link Neo4jHeuristicsCalculator} end-to-end (parser → calculator) against two worked + * examples, plus unit checks of the building blocks. Queries here are already in canonical form (no + * quantified path patterns, no variable-length edges) — that expansion is a separate, later step. + *

+ * Note on exact values: the heuristic calls EvoMaster's real {@code TruthnessUtils} (equality + * {@code 1/(1+d)}, less-than {@code 1/(1.1+d)}, both un-based), so the tests assert the real computed + * value and, more importantly, the qualitative structure and the gradient (a one-property mutation + * flips the query to satisfied). + */ +class Neo4jHeuristicsCalculatorTest { + + private final CypherParser parser = CypherParserFactory.buildParser(); + private final Neo4jHeuristicsCalculator calculator = new Neo4jHeuristicsCalculator(); + + private static final String EXAMPLE_QUERY = + "MATCH (a:Person {age: 25})-[r:KNOWS]->(b:Person) WHERE b.age > 30 RETURN b"; + + private Neo4jGraph example1Graph(int n2Age) { + Neo4jNode n1 = node("n1", labels("Person"), props("age", 25, "name", "Ana")); + Neo4jNode n2 = node("n2", labels("Person"), props("age", n2Age, "name", "Luis")); + Neo4jNode n3 = node("n3", labels("Animal"), props("age", 5, "name", "Rex")); + Neo4jNode n4 = node("n4", labels("Person"), props("age", 40, "name", "Carlos")); + Neo4jEdge e1 = rel("e1", "KNOWS", "n1", "n2"); + Neo4jEdge e2 = rel("e2", "LIKES", "n1", "n3"); + Neo4jEdge e3 = rel("e3", "KNOWS", "n3", "n4"); + return new Neo4jGraph(Arrays.asList(n1, n2, n3, n4), Arrays.asList(e1, e2, e3)); + } + + @Test + void testExample1StructuralMappings() throws CypherParserException { + MatchOperation q = parser.parse(EXAMPLE_QUERY); + assertEquals(3, new Neo4jStructuralMatcher() + .matchedElements(q.getPattern(), example1Graph(28)).size()); + } + + @Test + void testExample1NotSatisfiedButStrongGradient() throws CypherParserException { + MatchOperation q = parser.parse(EXAMPLE_QUERY); + Truthness h = calculator.computeHeuristic(q, example1Graph(28)); + + // Not satisfied: b.age (28) is not > 30 on any mapping. + assertFalse(h.isTrue()); + assertEquals(1.0, h.getOfFalse(), 1e-9); + // Structure fully available and the best mapping satisfies 4 of 5 conditions: close to satisfied. + assertTrue(h.getOfTrue() > 0.9); + } + + @Test + void testComputeDistanceMirrorsHeuristic() throws CypherParserException { + MatchOperation q = parser.parse(EXAMPLE_QUERY); + Neo4jGraph g = example1Graph(28); + // The distance reported to the fitness DTO is 1 - ofTrue, and is positive for an unsatisfied query. + double distance = calculator.computeDistance(q, g); + assertEquals(1.0 - calculator.computeHeuristic(q, g).getOfTrue(), distance, 1e-9); + assertTrue(distance > 0); + } + + @Test + void testExample1SatisfiedAfterAgeMutation() throws CypherParserException { + MatchOperation q = parser.parse(EXAMPLE_QUERY); + // Mutating Luis's age 28 → 31 makes the best mapping (a→n1, b→n2) fully satisfy the query. + Truthness h = calculator.computeHeuristic(q, example1Graph(31)); + assertTrue(h.isTrue()); + assertEquals(1.0, h.getOfTrue(), 1e-9); + } + + // Worked example 2: partial match, clear gradient + + private Neo4jGraph example2Graph() { + Neo4jNode n1 = node("n1", labels("Person"), props("age", 27, "name", "Ana")); + Neo4jNode n2 = node("n2", labels("Person"), props("age", 35, "name", "Luis")); + Neo4jNode n3 = node("n3", labels("Person"), props("age", 22, "name", "Maria")); + Neo4jEdge e1 = rel("e1", "LIKES", "n1", "n2"); + Neo4jEdge e2 = rel("e2", "KNOWS", "n2", "n3"); + return new Neo4jGraph(Arrays.asList(n1, n2, n3), Arrays.asList(e1, e2)); + } + + @Test + void testExample2PartialMatch() throws CypherParserException { + MatchOperation q = parser.parse(EXAMPLE_QUERY); + Truthness h = calculator.computeHeuristic(q, example2Graph()); + + assertFalse(h.isTrue()); + assertEquals(1.0, h.getOfFalse(), 1e-9); + // Partial match: a clear gradient, well above the unsatisfiable floor. + assertTrue(h.getOfTrue() > 0.8); + } + + @Test + void testExample2SatisfiedOnTunedGraph() throws CypherParserException { + MatchOperation q = parser.parse(EXAMPLE_QUERY); + // a:Person{age:25} -KNOWS-> b:Person{age:40>30} + Neo4jNode a = node("a", labels("Person"), props("age", 25)); + Neo4jNode b = node("b", labels("Person"), props("age", 40)); + Neo4jGraph g = new Neo4jGraph(Arrays.asList(a, b), + Collections.singletonList(rel("e", "KNOWS", "a", "b"))); + assertTrue(calculator.computeHeuristic(q, g).isTrue()); + } + + @Test + void testNoStructuralMatchStillScoresNodesByCount() throws CypherParserException { + MatchOperation q = parser.parse(EXAMPLE_QUERY); + // Right nodes exist but there is no relationship: matched_elements is empty, so H_where and + // H_match_edges are FALSE, but H_match_nodes is still TRUE (2 needed ≤ 2 available). + Neo4jNode a = node("a", labels("Person"), props("age", 25)); + Neo4jNode b = node("b", labels("Person"), props("age", 40)); + Neo4jGraph g = new Neo4jGraph(Arrays.asList(a, b), Collections.emptyList()); + Truthness h = calculator.computeHeuristic(q, g); + assertFalse(h.isTrue()); + assertEquals(1.0, h.getOfFalse(), 1e-9); + assertTrue(h.getOfTrue() > Neo4jHeuristicsCalculator.C); + } + + // Unit checks of the building blocks + + @Test + void testHMatchNodesCountBased() { + // enough nodes → TRUE + assertTrue(calculator.computeHeuristicMatchNodes(2, 4).isTrue()); + assertTrue(calculator.computeHeuristicMatchNodes(2, 2).isTrue()); + // no nodes in graph → FALSE + assertEquals(1.0, calculator.computeHeuristicMatchNodes(2, 0).getOfFalse(), 1e-9); + assertFalse(calculator.computeHeuristicMatchNodes(2, 0).isTrue()); + // no nodes required → TRUE + assertTrue(calculator.computeHeuristicMatchNodes(0, 0).isTrue()); + // partial availability → scaled in (C, 1) + Truthness partial = calculator.computeHeuristicMatchNodes(4, 2); + assertEquals(1.0, partial.getOfFalse(), 1e-9); + assertEquals(Neo4jHeuristicsCalculator.C + 0.9 * (2.0 / 4.0), partial.getOfTrue(), 1e-9); + } + + @Test + void testStringEqualityTruthness() { + assertTrue(Neo4jConditionEvaluator.stringEqualityTruthness("KNOWS", "KNOWS").isTrue()); + Truthness diff = Neo4jConditionEvaluator.stringEqualityTruthness("KNOWS", "LIKES"); + assertEquals(1.0, diff.getOfFalse(), 1e-9); + assertTrue(diff.getOfTrue() < 1.0); + } + + @Test + void testLabelInSet() { + assertTrue(Neo4jConditionEvaluator.labelInSet("Person", labels("Person")).isTrue()); + assertEquals(1.0, + Neo4jConditionEvaluator.labelInSet("Person", labels()).getOfFalse(), 1e-9); + assertFalse(Neo4jConditionEvaluator.labelInSet("Person", labels()).isTrue()); + // present-but-different label: scaled, never reaches 1 + Truthness t = Neo4jConditionEvaluator.labelInSet("Person", labels("Animal")); + assertEquals(1.0, t.getOfFalse(), 1e-9); + assertTrue(t.getOfTrue() >= Neo4jHeuristicsCalculator.C && t.getOfTrue() < 1.0); + } + + @Test + void testStartsWith() { + assertTrue(Neo4jConditionEvaluator.getStartsWith("hello", "hel").isTrue()); + Truthness t = Neo4jConditionEvaluator.getStartsWith("hello", "xyz"); + assertEquals(1.0, t.getOfFalse(), 1e-9); + assertTrue(t.getOfTrue() >= Neo4jHeuristicsCalculator.C && t.getOfTrue() < 1.0); + } + + // helpers + + private static Neo4jNode node(String id, Set labels, Map props) { + return new Neo4jNode(id, labels, props); + } + + private static Neo4jEdge rel(String id, String type, String from, String to) { + return new Neo4jEdge(id, type, from, to, Collections.emptyMap()); + } + + private static Set labels(String... ls) { + return new LinkedHashSet<>(Arrays.asList(ls)); + } + + private static Map props(Object... kv) { + Map m = new LinkedHashMap<>(); + for (int i = 0; i < kv.length; i += 2) { + m.put((String) kv[i], kv[i + 1]); + } + return m; + } +} From af9a6246f1cdd71b00c5caa61f6494e2bdb53d40 Mon Sep 17 00:00:00 2001 From: Andres Felder <81707831+andyfelder16@users.noreply.github.com> Date: Tue, 21 Jul 2026 11:30:46 -0300 Subject: [PATCH 2/3] cleaned test file --- .../Neo4jHeuristicsCalculatorTest.java | 44 +++++++++---------- 1 file changed, 21 insertions(+), 23 deletions(-) diff --git a/client-java/controller/src/test/java/org/evomaster/client/java/controller/neo4j/heuristics/Neo4jHeuristicsCalculatorTest.java b/client-java/controller/src/test/java/org/evomaster/client/java/controller/neo4j/heuristics/Neo4jHeuristicsCalculatorTest.java index 87279aa61c..1bd8fefebf 100644 --- a/client-java/controller/src/test/java/org/evomaster/client/java/controller/neo4j/heuristics/Neo4jHeuristicsCalculatorTest.java +++ b/client-java/controller/src/test/java/org/evomaster/client/java/controller/neo4j/heuristics/Neo4jHeuristicsCalculatorTest.java @@ -20,7 +20,7 @@ import static org.junit.jupiter.api.Assertions.*; /** - * Validates {@link Neo4jHeuristicsCalculator} end-to-end (parser → calculator) against two worked + * Validates {@link Neo4jHeuristicsCalculator} end-to-end (parser -> calculator) against two worked * examples, plus unit checks of the building blocks. Queries here are already in canonical form (no * quantified path patterns, no variable-length edges) — that expansion is a separate, later step. *

@@ -31,6 +31,8 @@ */ class Neo4jHeuristicsCalculatorTest { + private static final double DELTA = 1e-9; + private final CypherParser parser = CypherParserFactory.buildParser(); private final Neo4jHeuristicsCalculator calculator = new Neo4jHeuristicsCalculator(); @@ -62,7 +64,7 @@ void testExample1NotSatisfiedButStrongGradient() throws CypherParserException { // Not satisfied: b.age (28) is not > 30 on any mapping. assertFalse(h.isTrue()); - assertEquals(1.0, h.getOfFalse(), 1e-9); + assertEquals(1.0, h.getOfFalse(), DELTA); // Structure fully available and the best mapping satisfies 4 of 5 conditions: close to satisfied. assertTrue(h.getOfTrue() > 0.9); } @@ -73,17 +75,17 @@ void testComputeDistanceMirrorsHeuristic() throws CypherParserException { Neo4jGraph g = example1Graph(28); // The distance reported to the fitness DTO is 1 - ofTrue, and is positive for an unsatisfied query. double distance = calculator.computeDistance(q, g); - assertEquals(1.0 - calculator.computeHeuristic(q, g).getOfTrue(), distance, 1e-9); + assertEquals(1.0 - calculator.computeHeuristic(q, g).getOfTrue(), distance, DELTA); assertTrue(distance > 0); } @Test void testExample1SatisfiedAfterAgeMutation() throws CypherParserException { MatchOperation q = parser.parse(EXAMPLE_QUERY); - // Mutating Luis's age 28 → 31 makes the best mapping (a→n1, b→n2) fully satisfy the query. + // Mutating Luis age 28 -> 31 makes the best mapping (a->n1, b->n2) fully satisfy the query. Truthness h = calculator.computeHeuristic(q, example1Graph(31)); assertTrue(h.isTrue()); - assertEquals(1.0, h.getOfTrue(), 1e-9); + assertEquals(1.0, h.getOfTrue(), DELTA); } // Worked example 2: partial match, clear gradient @@ -103,7 +105,7 @@ void testExample2PartialMatch() throws CypherParserException { Truthness h = calculator.computeHeuristic(q, example2Graph()); assertFalse(h.isTrue()); - assertEquals(1.0, h.getOfFalse(), 1e-9); + assertEquals(1.0, h.getOfFalse(), DELTA); // Partial match: a clear gradient, well above the unsatisfiable floor. assertTrue(h.getOfTrue() > 0.8); } @@ -129,33 +131,31 @@ void testNoStructuralMatchStillScoresNodesByCount() throws CypherParserException Neo4jGraph g = new Neo4jGraph(Arrays.asList(a, b), Collections.emptyList()); Truthness h = calculator.computeHeuristic(q, g); assertFalse(h.isTrue()); - assertEquals(1.0, h.getOfFalse(), 1e-9); + assertEquals(1.0, h.getOfFalse(), DELTA); assertTrue(h.getOfTrue() > Neo4jHeuristicsCalculator.C); } - // Unit checks of the building blocks - @Test void testHMatchNodesCountBased() { - // enough nodes → TRUE + // enough nodes -> TRUE assertTrue(calculator.computeHeuristicMatchNodes(2, 4).isTrue()); assertTrue(calculator.computeHeuristicMatchNodes(2, 2).isTrue()); - // no nodes in graph → FALSE - assertEquals(1.0, calculator.computeHeuristicMatchNodes(2, 0).getOfFalse(), 1e-9); + // no nodes in graph -> FALSE + assertEquals(1.0, calculator.computeHeuristicMatchNodes(2, 0).getOfFalse(), DELTA); assertFalse(calculator.computeHeuristicMatchNodes(2, 0).isTrue()); - // no nodes required → TRUE + // no nodes required -> TRUE assertTrue(calculator.computeHeuristicMatchNodes(0, 0).isTrue()); - // partial availability → scaled in (C, 1) + // partial availability -> scaled in (C, 1) Truthness partial = calculator.computeHeuristicMatchNodes(4, 2); - assertEquals(1.0, partial.getOfFalse(), 1e-9); - assertEquals(Neo4jHeuristicsCalculator.C + 0.9 * (2.0 / 4.0), partial.getOfTrue(), 1e-9); + assertEquals(1.0, partial.getOfFalse(), DELTA); + assertEquals(Neo4jHeuristicsCalculator.C + 0.9 * (2.0 / 4.0), partial.getOfTrue(), DELTA); } @Test void testStringEqualityTruthness() { assertTrue(Neo4jConditionEvaluator.stringEqualityTruthness("KNOWS", "KNOWS").isTrue()); Truthness diff = Neo4jConditionEvaluator.stringEqualityTruthness("KNOWS", "LIKES"); - assertEquals(1.0, diff.getOfFalse(), 1e-9); + assertEquals(1.0, diff.getOfFalse(), DELTA); assertTrue(diff.getOfTrue() < 1.0); } @@ -163,11 +163,11 @@ void testStringEqualityTruthness() { void testLabelInSet() { assertTrue(Neo4jConditionEvaluator.labelInSet("Person", labels("Person")).isTrue()); assertEquals(1.0, - Neo4jConditionEvaluator.labelInSet("Person", labels()).getOfFalse(), 1e-9); + Neo4jConditionEvaluator.labelInSet("Person", labels()).getOfFalse(), DELTA); assertFalse(Neo4jConditionEvaluator.labelInSet("Person", labels()).isTrue()); - // present-but-different label: scaled, never reaches 1 + // present but different label: scaled, never reaches 1 Truthness t = Neo4jConditionEvaluator.labelInSet("Person", labels("Animal")); - assertEquals(1.0, t.getOfFalse(), 1e-9); + assertEquals(1.0, t.getOfFalse(), DELTA); assertTrue(t.getOfTrue() >= Neo4jHeuristicsCalculator.C && t.getOfTrue() < 1.0); } @@ -175,12 +175,10 @@ void testLabelInSet() { void testStartsWith() { assertTrue(Neo4jConditionEvaluator.getStartsWith("hello", "hel").isTrue()); Truthness t = Neo4jConditionEvaluator.getStartsWith("hello", "xyz"); - assertEquals(1.0, t.getOfFalse(), 1e-9); + assertEquals(1.0, t.getOfFalse(), DELTA); assertTrue(t.getOfTrue() >= Neo4jHeuristicsCalculator.C && t.getOfTrue() < 1.0); } - // helpers - private static Neo4jNode node(String id, Set labels, Map props) { return new Neo4jNode(id, labels, props); } From 2d0e890efb7e7bea02c643686c14fb92bedadc8f Mon Sep 17 00:00:00 2001 From: Andres Felder <81707831+andyfelder16@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:33:11 -0300 Subject: [PATCH 3/3] dispatch cypher conditions through a visitor and fix review comments --- .../neo4j/conditions/AndCondition.java | 5 + .../neo4j/conditions/AnyLabelCondition.java | 5 + .../neo4j/conditions/ComparisonCondition.java | 5 + .../neo4j/conditions/CypherCondition.java | 6 + .../conditions/CypherConditionVisitor.java | 32 +++++ .../neo4j/conditions/LabelCondition.java | 5 + .../neo4j/conditions/NotCondition.java | 5 + .../neo4j/conditions/OrCondition.java | 5 + .../neo4j/conditions/PropertyCondition.java | 5 + .../neo4j/conditions/RawCondition.java | 5 + .../neo4j/conditions/TypeCondition.java | 5 + .../neo4j/conditions/XorCondition.java | 5 + .../heuristics/Neo4jConditionEvaluator.java | 119 ++++++++++++++---- .../heuristics/Neo4jHeuristicsCalculator.java | 7 +- .../neo4j/heuristics/Neo4jMapping.java | 9 +- .../Neo4jHeuristicsCalculatorTest.java | 21 ++++ 16 files changed, 215 insertions(+), 29 deletions(-) create mode 100644 client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/CypherConditionVisitor.java diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/AndCondition.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/AndCondition.java index 4c7a4e80cc..c875285b17 100644 --- a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/AndCondition.java +++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/AndCondition.java @@ -20,6 +20,11 @@ public List getConditions() { return conditions; } + @Override + public T accept(CypherConditionVisitor visitor) { + return visitor.visitAnd(this); + } + @Override public String toString() { StringBuilder sb = new StringBuilder("("); diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/AnyLabelCondition.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/AnyLabelCondition.java index 4a3ca34ecb..311b7df051 100644 --- a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/AnyLabelCondition.java +++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/AnyLabelCondition.java @@ -18,6 +18,11 @@ public String getVariableName() { return variableName; } + @Override + public T accept(CypherConditionVisitor visitor) { + return visitor.visitAnyLabel(this); + } + @Override public String toString() { return variableName + ":%"; diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/ComparisonCondition.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/ComparisonCondition.java index 4db79a2a68..b8019297db 100644 --- a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/ComparisonCondition.java +++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/ComparisonCondition.java @@ -31,6 +31,11 @@ public Operand getRight() { return right; } + @Override + public T accept(CypherConditionVisitor visitor) { + return visitor.visitComparison(this); + } + @Override public String toString() { if (right == null) { diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/CypherCondition.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/CypherCondition.java index 7fea8e2348..5ffe33e7a5 100644 --- a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/CypherCondition.java +++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/CypherCondition.java @@ -11,4 +11,10 @@ * - Logical operators: AND, OR, NOT */ public interface CypherCondition { + + /** + * Dispatches to the {@code visit} method of {@code visitor} matching this condition's concrete + * type (double dispatch), returning whatever the visitor produces for it. + */ + T accept(CypherConditionVisitor visitor); } diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/CypherConditionVisitor.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/CypherConditionVisitor.java new file mode 100644 index 0000000000..085235ac05 --- /dev/null +++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/CypherConditionVisitor.java @@ -0,0 +1,32 @@ +package org.evomaster.client.java.controller.neo4j.conditions; + +/** + * Visitor over the typed {@link CypherCondition} boolean tree the parser produces. Each concrete + * condition dispatches to its own {@code visit} method through {@link CypherCondition#accept}, so a + * consumer handles every case explicitly and the compiler flags any implementation that forgets one + * (no {@code instanceof} chain, no silent fall-through for an unhandled condition type). + * + * @param the result type each visit produces (e.g. a {@code Truthness} for the heuristics). + */ +public interface CypherConditionVisitor { + + T visitLabel(LabelCondition condition); + + T visitAnyLabel(AnyLabelCondition condition); + + T visitType(TypeCondition condition); + + T visitProperty(PropertyCondition condition); + + T visitComparison(ComparisonCondition condition); + + T visitAnd(AndCondition condition); + + T visitOr(OrCondition condition); + + T visitXor(XorCondition condition); + + T visitNot(NotCondition condition); + + T visitRaw(RawCondition condition); +} diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/LabelCondition.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/LabelCondition.java index 46260e5558..8aa1a52b6b 100644 --- a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/LabelCondition.java +++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/LabelCondition.java @@ -24,6 +24,11 @@ public String getLabel() { return label; } + @Override + public T accept(CypherConditionVisitor visitor) { + return visitor.visitLabel(this); + } + @Override public String toString() { return variableName + ":" + label; diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/NotCondition.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/NotCondition.java index b5fa190463..9181a50c87 100644 --- a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/NotCondition.java +++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/NotCondition.java @@ -17,6 +17,11 @@ public CypherCondition getCondition() { return condition; } + @Override + public T accept(CypherConditionVisitor visitor) { + return visitor.visitNot(this); + } + @Override public String toString() { return "NOT " + condition; diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/OrCondition.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/OrCondition.java index c05d2058e0..56c33d74f7 100644 --- a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/OrCondition.java +++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/OrCondition.java @@ -20,6 +20,11 @@ public List getConditions() { return conditions; } + @Override + public T accept(CypherConditionVisitor visitor) { + return visitor.visitOr(this); + } + @Override public String toString() { StringBuilder sb = new StringBuilder("("); diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/PropertyCondition.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/PropertyCondition.java index 94d7722338..9cf1b632db 100644 --- a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/PropertyCondition.java +++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/PropertyCondition.java @@ -32,6 +32,11 @@ public Operand getValue() { return value; } + @Override + public T accept(CypherConditionVisitor visitor) { + return visitor.visitProperty(this); + } + @Override public String toString() { return variableName + "." + propertyKey + " = " + value; diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/RawCondition.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/RawCondition.java index b4c2a00291..f6d74ea3f3 100644 --- a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/RawCondition.java +++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/RawCondition.java @@ -20,6 +20,11 @@ public String getExpression() { return expression; } + @Override + public T accept(CypherConditionVisitor visitor) { + return visitor.visitRaw(this); + } + @Override public String toString() { return expression; diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/TypeCondition.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/TypeCondition.java index 364b09f096..e28f2da7f7 100644 --- a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/TypeCondition.java +++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/TypeCondition.java @@ -24,6 +24,11 @@ public String getType() { return type; } + @Override + public T accept(CypherConditionVisitor visitor) { + return visitor.visitType(this); + } + @Override public String toString() { return "type(" + variableName + ") = " + type; diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/XorCondition.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/XorCondition.java index e8f09d005f..d850a8b81b 100644 --- a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/XorCondition.java +++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/XorCondition.java @@ -20,6 +20,11 @@ public List getConditions() { return conditions; } + @Override + public T accept(CypherConditionVisitor visitor) { + return visitor.visitXor(this); + } + @Override public String toString() { StringBuilder sb = new StringBuilder("("); diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/heuristics/Neo4jConditionEvaluator.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/heuristics/Neo4jConditionEvaluator.java index d8968431e3..2995783b0e 100644 --- a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/heuristics/Neo4jConditionEvaluator.java +++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/heuristics/Neo4jConditionEvaluator.java @@ -10,6 +10,7 @@ import java.util.ArrayList; import java.util.List; +import java.util.Objects; import java.util.Set; import static org.evomaster.client.java.controller.neo4j.heuristics.Neo4jHeuristicsCalculator.C; @@ -41,23 +42,47 @@ class Neo4jConditionEvaluator { /** * Returns {@code ρ(condition, mapping)}, or {@code null} when the condition cannot be evaluated - * and must be skipped by the aggregation. + * under the mapping and must be skipped by the aggregation (an absent property, an unbound + * variable, or an opaque {@link RawCondition}). Dispatch is done with a + * {@link CypherConditionVisitor} so every condition type is handled explicitly — there is no + * {@code instanceof} chain and no silent fall-through for an unhandled type. */ Truthness evaluateCondition(CypherCondition condition, Neo4jMapping mapping) { - if (condition instanceof LabelCondition) { - LabelCondition lc = (LabelCondition) condition; + Objects.requireNonNull(condition, "condition must not be null"); + Objects.requireNonNull(mapping, "mapping must not be null"); + return condition.accept(new TruthnessVisitor(mapping)); + } + + /** + * Computes {@code ρ} for one condition under a fixed mapping. A fresh instance is created per + * top-level condition (it carries the mapping), and it recurses through {@link #evaluateCondition} + * for the boolean-tree children, so nested conditions dispatch through the same visitor. + */ + private final class TruthnessVisitor implements CypherConditionVisitor { + + private final Neo4jMapping mapping; + + private TruthnessVisitor(Neo4jMapping mapping) { + this.mapping = mapping; + } + + @Override + public Truthness visitLabel(LabelCondition lc) { Neo4jNode node = mapping.getNode(lc.getVariableName()); return node == null ? null : labelInSet(lc.getLabel(), node.getLabels()); } - if (condition instanceof AnyLabelCondition) { - Neo4jNode node = mapping.getNode(((AnyLabelCondition) condition).getVariableName()); + + @Override + public Truthness visitAnyLabel(AnyLabelCondition ac) { + Neo4jNode node = mapping.getNode(ac.getVariableName()); if (node == null) { return null; } return node.getLabels().isEmpty() ? FALSE_TRUTHNESS : TRUE_TRUTHNESS; } - if (condition instanceof TypeCondition) { - TypeCondition tc = (TypeCondition) condition; + + @Override + public Truthness visitType(TypeCondition tc) { Neo4jEdge rel = mapping.getEdge(tc.getVariableName()); if (rel == null) { return null; @@ -65,26 +90,45 @@ Truthness evaluateCondition(CypherCondition condition, Neo4jMapping mapping) { taintStringEquals(rel.getType(), tc.getType()); return stringEqualityTruthness(rel.getType(), tc.getType()); } - if (condition instanceof PropertyCondition) { - return evaluateProperty((PropertyCondition) condition, mapping); + + @Override + public Truthness visitProperty(PropertyCondition pc) { + return evaluateProperty(pc, mapping); } - if (condition instanceof ComparisonCondition) { - return evaluateComparison((ComparisonCondition) condition, mapping); + + @Override + public Truthness visitComparison(ComparisonCondition cc) { + return evaluateComparison(cc, mapping); } - if (condition instanceof AndCondition) { - return aggregate(((AndCondition) condition).getConditions(), mapping, true); + + @Override + public Truthness visitAnd(AndCondition ac) { + return aggregate(ac.getConditions(), mapping, true); } - if (condition instanceof OrCondition) { - return aggregate(((OrCondition) condition).getConditions(), mapping, false); + + @Override + public Truthness visitOr(OrCondition oc) { + return aggregate(oc.getConditions(), mapping, false); } - if (condition instanceof XorCondition) { - return evaluateXor(((XorCondition) condition).getConditions(), mapping); + + @Override + public Truthness visitXor(XorCondition xc) { + return evaluateXor(xc.getConditions(), mapping); } - if (condition instanceof NotCondition) { - Truthness inner = evaluateCondition(((NotCondition) condition).getCondition(), mapping); + + @Override + public Truthness visitNot(NotCondition nc) { + Truthness inner = evaluateCondition(nc.getCondition(), mapping); return inner == null ? null : inner.invert(); } - return null; + + @Override + public Truthness visitRaw(RawCondition rc) { + // A RawCondition is a WHERE predicate the parser kept as raw text because it could not + // break it into operands. With no resolved operands there is no value to measure a distance + // against. + return null; + } } private Truthness evaluateProperty(PropertyCondition pc, Neo4jMapping mapping) { @@ -277,6 +321,13 @@ private Truthness presenceTruthness(Operand operand, Neo4jMapping mapping, boole return satisfied ? TRUE_TRUTHNESS : FALSE_TRUTHNESS; } + /** + * ρ for an equality {@code a = b} where either side is an already-valuated operand. The + * right-hand value may be the Cypher {@code null} literal (a {@link LiteralOperand} whose value + * is {@code null}); a {@code null} on either side yields {@code null} here, mirroring Cypher's + * ternary logic where any comparison against {@code null} is {@code null} (unknown) rather than + * true or false, so the aggregation skips it instead of scoring a gradient. + */ private Truthness equalityTruthness(Object a, Object b) { if (a == null || b == null) { return null; @@ -300,7 +351,15 @@ private void taintStringEquals(String a, String b) { } } + /** + * ρ for {@code a < b} on numeric operands. Both are required to be non-null: the caller only + * reaches this after resolving each side to a present value (a {@code null}/absent operand is + * filtered out before dispatch). A non-numeric value still yields {@code null} — there is no + * numeric gradient to compute — but a {@code null} argument is a contract violation, not that case. + */ private Truthness numericLessThan(Object a, Object b) { + Objects.requireNonNull(a, "left operand must not be null"); + Objects.requireNonNull(b, "right operand must not be null"); Double da = asDouble(a); Double db = asDouble(b); if (da == null || db == null) { @@ -314,11 +373,14 @@ private Truthness numericLessThan(Object a, Object b) { * is the left-alignment edit distance scaled from base {@code C} (so it never drops below {@code C}) * and {@code ofFalse = 1}. Used where a string is compared for equality on its own — relationship * types and string-valued property/WHERE equality. + *

+ * Both arguments must be non-null: this is only reached with two resolved strings (a relationship + * type, or two string values already confirmed present by {@link #equalityTruthness}). A Cypher + * {@code null} literal never reaches here — it is handled one level up by {@link #equalityTruthness}. */ static Truthness stringEqualityTruthness(String a, String b) { - if (a == null || b == null) { - return null; - } + Objects.requireNonNull(a, "a must not be null"); + Objects.requireNonNull(b, "b must not be null"); if (a.equals(b)) { return TRUE_TRUTHNESS; } @@ -334,9 +396,8 @@ static Truthness stringEqualityTruthness(String a, String b) { * on the label path (applying it here as well would double-count it). */ static Truthness stringSimilarityTruthness(String a, String b) { - if (a == null || b == null) { - return null; - } + Objects.requireNonNull(a, "a must not be null"); + Objects.requireNonNull(b, "b must not be null"); long distance = DistanceHelper.getLeftAlignmentDistance(a, b); double ofTrue = 1d - TruthnessUtils.normalizeValue((double) distance); return new Truthness(ofTrue, a.equals(b) ? 0d : 1d); @@ -371,6 +432,8 @@ private static Double asDouble(Object value) { } static Truthness getStartsWith(String str, String prefix) { + Objects.requireNonNull(str, "str must not be null"); + Objects.requireNonNull(prefix, "prefix must not be null"); if (str.startsWith(prefix)) { return TRUE_TRUTHNESS; } @@ -382,6 +445,8 @@ static Truthness getStartsWith(String str, String prefix) { } static Truthness getEndsWith(String str, String suffix) { + Objects.requireNonNull(str, "str must not be null"); + Objects.requireNonNull(suffix, "suffix must not be null"); if (str.endsWith(suffix)) { return TRUE_TRUTHNESS; } @@ -394,6 +459,8 @@ static Truthness getEndsWith(String str, String suffix) { } static Truthness getContains(String str, String substring) { + Objects.requireNonNull(str, "str must not be null"); + Objects.requireNonNull(substring, "substring must not be null"); if (str.contains(substring)) { return TRUE_TRUTHNESS; } diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/heuristics/Neo4jHeuristicsCalculator.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/heuristics/Neo4jHeuristicsCalculator.java index b1c1d9a6ba..939895e034 100644 --- a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/heuristics/Neo4jHeuristicsCalculator.java +++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/heuristics/Neo4jHeuristicsCalculator.java @@ -64,7 +64,8 @@ Truthness computeHeuristic(MatchOperation query, Neo4jGraph graph) { * {@code [0,1]}, where 0 means the query is satisfied. */ public double computeDistance(MatchOperation query, Neo4jGraph graph) { - return 1.0d - computeHeuristic(query, graph).getOfTrue(); + Truthness heuristic = computeHeuristic(query, graph); + return 1.0d - heuristic.getOfTrue(); } private Truthness computeHeuristicPattern(MatchPattern pattern, Neo4jGraph graph, List mappings) { @@ -78,6 +79,10 @@ private Truthness computeHeuristicPattern(MatchPattern pattern, Neo4jGraph graph * no label/property check (those are conditions evaluated by H_where). */ Truthness computeHeuristicMatchNodes(int required, int available) { + if (required < 0 || available < 0) { + throw new IllegalArgumentException( + "node counts must be non-negative, got required=" + required + ", available=" + available); + } if (required == 0) { return TRUE_TRUTHNESS; } diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/heuristics/Neo4jMapping.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/heuristics/Neo4jMapping.java index 63487f293d..60fc10190f 100644 --- a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/heuristics/Neo4jMapping.java +++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/heuristics/Neo4jMapping.java @@ -5,6 +5,7 @@ import java.util.LinkedHashMap; import java.util.Map; +import java.util.Objects; /** * A single structural mapping {@code m = (μ, ε)} from a query pattern {@code P_s} onto a graph @@ -25,8 +26,8 @@ class Neo4jMapping { } private Neo4jMapping(Map nodeBindings, Map edgeBindings) { - this.nodeBindings = new LinkedHashMap<>(nodeBindings); - this.edgeBindings = new LinkedHashMap<>(edgeBindings); + this.nodeBindings = new LinkedHashMap<>(Objects.requireNonNull(nodeBindings, "nodeBindings must not be null")); + this.edgeBindings = new LinkedHashMap<>(Objects.requireNonNull(edgeBindings, "edgeBindings must not be null")); } Neo4jMapping copy() { @@ -46,10 +47,14 @@ boolean isNodeBound(String variable) { } void bindNode(String variable, Neo4jNode node) { + Objects.requireNonNull(variable, "variable must not be null"); + Objects.requireNonNull(node, "node must not be null"); nodeBindings.put(variable, node); } void bindEdge(String variable, Neo4jEdge edge) { + Objects.requireNonNull(variable, "variable must not be null"); + Objects.requireNonNull(edge, "edge must not be null"); edgeBindings.put(variable, edge); } diff --git a/client-java/controller/src/test/java/org/evomaster/client/java/controller/neo4j/heuristics/Neo4jHeuristicsCalculatorTest.java b/client-java/controller/src/test/java/org/evomaster/client/java/controller/neo4j/heuristics/Neo4jHeuristicsCalculatorTest.java index 1bd8fefebf..2acf8dc6ac 100644 --- a/client-java/controller/src/test/java/org/evomaster/client/java/controller/neo4j/heuristics/Neo4jHeuristicsCalculatorTest.java +++ b/client-java/controller/src/test/java/org/evomaster/client/java/controller/neo4j/heuristics/Neo4jHeuristicsCalculatorTest.java @@ -179,6 +179,27 @@ void testStartsWith() { assertTrue(t.getOfTrue() >= Neo4jHeuristicsCalculator.C && t.getOfTrue() < 1.0); } + @Test + void testEqualityAgainstNullLiteralIsSkipped() throws CypherParserException { + // Cypher's null semantics: any comparison against null is null (unknown), never true or + // false, so `= null` is unsatisfiable. + Neo4jNode a = node("a", labels("Person"), props("name", "Ann")); + Neo4jNode b = node("b", labels("Person"), props("name", "Bob")); + Neo4jGraph g = new Neo4jGraph(Arrays.asList(a, b), + Collections.singletonList(rel("e", "KNOWS", "a", "b"))); + + Truthness baseline = calculator.computeHeuristic( + parser.parse("MATCH (a)-[r]->(b) RETURN b"), g); + Truthness whereNull = calculator.computeHeuristic( + parser.parse("MATCH (a)-[r]->(b) WHERE b.name = null RETURN b"), g); + Truthness inlineNull = calculator.computeHeuristic( + parser.parse("MATCH (a)-[r]->(b {name: null}) RETURN b"), g); + + assertTrue(baseline.isTrue()); + assertEquals(baseline.getOfTrue(), whereNull.getOfTrue(), DELTA); + assertEquals(baseline.getOfTrue(), inlineNull.getOfTrue(), DELTA); + } + private static Neo4jNode node(String id, Set labels, Map props) { return new Neo4jNode(id, labels, props); }