Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion config/xapi-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@
{
"type": "Interaction",
"class": "EntityAte",
"criteria": [["trigger", "key1"], "=", "thing1"],
"criteria": [
["trigger", ["PredatorId"]],
"!=",
["trigger", ["PreyId"]]
],
"lookups": {
"predator": {
"class": "SimEntity",
Expand Down
46 changes: 40 additions & 6 deletions doc/xapi-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,8 @@ Fields:

- `type`: Type of trigger. `Interaction` is currently wired into RTI subscriptions and statement processing. `ObjectUpdate` is parsed by the config model but object updates currently feed the object cache rather than firing statement triggers directly.
- `class`: The local HLA interaction class name. For interactions this is matched against the final segment of the RTI interaction class name.
- `criteria`: **NOTE: Not Yet Implemented!** Parsed as a criteria expression, but not currently applied when deciding whether an incoming interaction should fire the trigger.
- `lookups`: Optional named cache lookups resolved once before the statement template is processed. These can be used to reduce the size and complexity of queries in the body of the statement config.
- `criteria`: Optional expression evaluated before the statement template is processed. A non-matching trigger is skipped without producing an xAPI statement. A trigger without criteria always matches.
- `lookups`: Optional named cache lookups loaded on first use. A lookup result, including a missing result, is reused for the rest of that trigger attempt.
- `statement`: An xAPI statement template. Any JSON object accepted by the xAPI spec can be used here, with injection expressions inserted where dynamic values are needed.
- `skipValidation`: Optional flag to skip boot validation for xAPI statement template and injections. **NOTE: This may result in invalid statements being sent to LRS!** Only use if startup is throwing unnecessary validation errors for your template. If you encounter validation issues that you believe to be in error, please report them in a Github Issue.

Expand Down Expand Up @@ -89,7 +89,7 @@ Supported comparison operators:
- `<=`
- `>=`

The left or right side may be a target, primitive value, nested criterion, or a `trigger` expression.
The left or right side may be a target, primitive value, nested criterion, or an expression that reads from `trigger`, `query`, or `lookup`.

```json
[["Hunger"], ">", 50]
Expand All @@ -111,6 +111,40 @@ Use a single logical operator at a given array level. If mixed `and`/`or` logic

In cache queries, `=` compares numbers numerically when both sides are numeric; otherwise it uses normal equality. Ordered comparisons compare numbers numerically, comparable values of the same class directly, and otherwise fall back to string comparison.

### Trigger criteria

Statement-trigger criteria require an explicit value source. Use `trigger` to read the incoming event, `query` to read the first matching cached object, or `lookup` to read a named lookup. Bare targets remain reserved for the cached object being tested inside query and lookup filters.

```json
{
"lookups": {
"predator": {
"class": "SimEntity",
"criteria": [["EntityId"], "=", ["trigger", ["PredatorId"]]]
}
},
"criteria": [
["trigger", ["PredatorId"]],
"=",
["lookup", "predator", ["EntityId"]]
]
}
```

A query is also a value expression in trigger criteria:

```json
[
["query", "World", ["Size"], [["WorldId"], "=", ["trigger", ["WorldId"]]]],
">",
0
]
```

Query and lookup filters may contain cached targets, literals, nested comparisons/logical expressions, and `trigger` expressions. Nested queries and lookups are not allowed in cache filters. Injection rendering options such as `required` and `nullable` do not apply inside criteria.

Logical expressions short-circuit. A missing query object, lookup object, or target value resolves to `null` for comparison purposes. Other resolution errors fail trigger processing rather than being treated as a non-match.

## Statement Injections

An injection can appear as a whole JSON value:
Expand Down Expand Up @@ -222,14 +256,14 @@ A lookup injection has this shape:
["lookup", "predator", ["EntityId"]]
```

The alias must exist in the trigger's `lookups` map. Each lookup alias is resolved once before processing the statement template, and all `lookup` injections for that alias reuse the same cached object.
The alias must exist in the trigger's `lookups` map. An alias is resolved only when criteria evaluation or statement rendering first reads it. All later reads during that trigger attempt reuse the same cached object; a missing result is memoized as well.

## Object Cache

The object cache stores the latest reflected values for subscribed HLA object attributes in SQLite or PostgreSQL. It is enabled when either:

- a statement template contains a `query` injection,
- a trigger defines `lookups` or uses `lookup` injections that reference cached object attributes, or
- a statement template or trigger criterion contains a `query`,
- a trigger defines `lookups` or uses `lookup` expressions that reference cached object attributes, or
- `objectCache.trackedObjects` explicitly requests tracked attributes.

When enabled, the adapter subscribes to the top-level object attributes required by query targets, query criteria, lookup targets, lookup criteria, and explicit tracked objects. Use the `trackedObjects` array to force cacheing of simulation objects:
Expand Down
10 changes: 6 additions & 4 deletions src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,9 @@ public void stop() throws RTIinternalError {
public void validateConfig() throws XapiConfigurationException {
for(StatementTrigger st : xapiConfig.statementTriggers){
if (st.skipValidation) continue;
TriggerProcessingResult tpr = triggerProcessor.processTrigger(st, new TestInjectionContext(st.clazz));
TriggerProcessingResult tpr = triggerProcessor.renderTemplateForValidation(
st,
new TestInjectionContext(st.clazz));
if (tpr.success()) {
StatementValidationResult svr = validator.validateStatement(tpr.statement());
if (!svr.isValid()){
Expand Down Expand Up @@ -434,15 +436,15 @@ private void receiveInteraction(InteractionClassHandle interactionClass, Paramet
&& trigger.type.equals(StatementTrigger.Type.INTERACTION))
.forEach(trigger -> {
logger.trace("Processing trigger for interaction {}", trigger.clazz);
// TODO: this is nullable, implement DLQ
TriggerProcessingResult result = triggerProcessor.processTrigger(trigger, context);
if (result.success()){
if (result.success() && result.matched()){
try {
xapiClient.sendStatement(result.statement());
} catch (Exception e) {
logger.error("Error parsing or posting statement {}", result.statement(), e);
}
} else {
} else if (!result.success()) {
// TODO: DLQ
logger.error("Error processing Interaction: {}", result.error().getMessage(),
result.error());
}
Expand Down
28 changes: 8 additions & 20 deletions src/main/java/com/yetanalytics/hlaxapi/InjectionHandler.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,8 @@
import com.yetanalytics.hlaxapi.cache.CachedObject;
import com.yetanalytics.hlaxapi.cache.ObjectCache;
import com.yetanalytics.hlaxapi.cache.ValueResolution;
import com.yetanalytics.hlaxapi.config.model.Criterion;
import com.yetanalytics.hlaxapi.config.model.Expression;
import com.yetanalytics.hlaxapi.config.model.LogicalExpression;
import com.yetanalytics.hlaxapi.config.model.ExpressionWalker;
import com.yetanalytics.hlaxapi.config.model.ObjectLookup;
import com.yetanalytics.hlaxapi.config.model.Target;
import com.yetanalytics.hlaxapi.config.model.TriggerExpression;
Expand Down Expand Up @@ -287,24 +286,13 @@ private Expression resolveTriggerExpressions(Expression expression, InjectionCon
if (expression == null || context == null) {
return expression;
}
if (expression instanceof TriggerExpression triggerExpression) {
ValueResolution vr = handleTrigger(triggerExpression.target, context);
return new ValueExpression(vr.value());
}
if (expression instanceof Criterion criterion) {
return new Criterion(
resolveTriggerExpressions(criterion.left, context),
criterion.operator,
resolveTriggerExpressions(criterion.right, context));
}
if (expression instanceof LogicalExpression logicalExpression) {
return new LogicalExpression(
logicalExpression.operator,
logicalExpression.operands.stream()
.map(operand -> resolveTriggerExpressions(operand, context))
.toList());
}
return expression;
return ExpressionWalker.rewrite(expression, candidate -> {
if (candidate instanceof TriggerExpression triggerExpression) {
ValueResolution resolution = handleTrigger(triggerExpression.target, context);
return new ValueExpression(resolution.value());
}
return candidate;
});
}

// for test
Expand Down
45 changes: 45 additions & 0 deletions src/main/java/com/yetanalytics/hlaxapi/LazyLookupContext.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.yetanalytics.hlaxapi;

import com.yetanalytics.hlaxapi.cache.CachedObject;
import com.yetanalytics.hlaxapi.cache.ValueResolution;
import com.yetanalytics.hlaxapi.config.model.ObjectLookup;
import com.yetanalytics.hlaxapi.config.model.Target;
import com.yetanalytics.hlaxapi.injection.InjectionContext;
import com.yetanalytics.hlaxapi.injection.TestInjectionContext;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;

/** Holds lookup results that are scoped to one statement-trigger attempt. */
final class LazyLookupContext {

private final InjectionHandler handler;
private final InjectionContext injectionContext;
private final Map<String, ObjectLookup> definitions;
private final Map<String, Optional<CachedObject>> objects = new HashMap<>();

LazyLookupContext(
InjectionHandler handler,
InjectionContext injectionContext,
Map<String, ObjectLookup> definitions) {
this.handler = handler;
this.injectionContext = injectionContext;
this.definitions = definitions == null ? Map.of() : definitions;
}

ValueResolution value(String alias, Target target) {
if (injectionContext instanceof TestInjectionContext) {
return handler.handleLookup(null, target, injectionContext);
}
return handler.handleLookup(object(alias), target, injectionContext);
}

private CachedObject object(String alias) {
return objects.computeIfAbsent(alias, this::load).orElse(null);
}

private Optional<CachedObject> load(String alias) {
Optional<CachedObject> result = handler.resolveLookup(definitions.get(alias), injectionContext);
return result == null ? Optional.empty() : result;
}
}
43 changes: 43 additions & 0 deletions src/main/java/com/yetanalytics/hlaxapi/TriggerCriteriaMatcher.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.yetanalytics.hlaxapi;

import com.yetanalytics.hlaxapi.cache.ValueResolution;
import com.yetanalytics.hlaxapi.config.model.Expression;
import com.yetanalytics.hlaxapi.config.model.LookupExpression;
import com.yetanalytics.hlaxapi.config.model.QueryExpression;
import com.yetanalytics.hlaxapi.config.model.Target;
import com.yetanalytics.hlaxapi.config.model.TriggerExpression;
import com.yetanalytics.hlaxapi.criteria.CriteriaEvaluator;
import com.yetanalytics.hlaxapi.injection.InjectionContext;

/** Evaluates a statement trigger's expression against its runtime value sources. */
final class TriggerCriteriaMatcher {

private final InjectionHandler handler;
private final CriteriaEvaluator evaluator = new CriteriaEvaluator();

TriggerCriteriaMatcher(InjectionHandler handler) {
this.handler = handler;
}

boolean matches(Expression criteria, InjectionContext context, LazyLookupContext lookups) {
return evaluator.matches(criteria, expression -> resolve(expression, context, lookups));
}

private Object resolve(Expression expression, InjectionContext context, LazyLookupContext lookups) {
ValueResolution resolution;
if (expression instanceof TriggerExpression trigger) {
resolution = handler.handleTrigger(trigger.target, context);
} else if (expression instanceof QueryExpression query) {
resolution = handler.handleQuery(query.clazz, query.target, query.criteria, context);
} else if (expression instanceof LookupExpression lookup) {
resolution = lookups.value(lookup.alias, lookup.target);
} else if (expression instanceof Target target) {
throw new IllegalStateException(
"Bare target " + target.parts + " reached statement-trigger evaluation");
} else {
throw new IllegalStateException(
"Unsupported statement-trigger expression " + expression.getClass().getSimpleName());
}
return resolution != null && resolution.present() ? resolution.value() : null;
}
}
Loading
Loading