kestrel.markets
Version:
A typed, token-efficient language + runtime for agentic trading: agents author bounded plans, the runtime fires them at the tick. CLI + typed library + MCP server.
415 lines (414 loc) • 21.9 kB
JavaScript
/**
* # series/trigger — the tri-state, causal trigger evaluator (RUNTIME §3)
*
* Evaluates a `Trigger` (from `src/lang`) to `true | false | UNKNOWN`. **Only a definite
* `true` fires** (RUNTIME §3). One {@link TriggerEvaluator} instance belongs to one armed
* statement instance and owns that instance's **per-node causal memory** — the edge/anchor
* state that makes crosses, break-holds, withins, and nth-events causal rather than
* level-only.
*
* ## The exact tri-state algebra (RUNTIME §3)
* - **AND**: `false` wins; else `UNKNOWN` if any term is UNKNOWN; else `true`.
* - **OR**: `true` wins; else `UNKNOWN` if any term is UNKNOWN; else `false`.
* - **NOT**: `UNKNOWN` maps to `UNKNOWN`; otherwise it negates.
* - Every operand UNKNOWN propagates — a comparison/cross over an UNKNOWN operand is
* UNKNOWN, never silently `false`.
*
* ## All subtrees evaluate every tick (causal-memory correctness)
* AND/OR do **not** short-circuit their traversal: every child is evaluated in full each
* tick *before* the tri-state is folded, so a `crosses`/`held`/`within`/`nth` buried under a
* sibling that already decided the parent still advances its own edge memory. Short-circuit
* would silently drop the transition sample that makes those predicates causal.
*
* ## Per-node causal memory (RUNTIME §3)
* - `crosses above|below` fires only on an **observed transition** — it needs a prior sample
* strictly on the *other* side; never true on first sight. Straddling equality holds the
* last non-equal anchor (does not overwrite it). `touches above|below` is the at-or-touch
* variant: reaching the level (`>=`/`<=`) counts as the target side, so an exact touch fires.
* A `band` adds a **re-arm** gate: after a fire the cross is disarmed until the
* series clears back past `right ∓ band`, so an in-band wiggle cannot phantom-fire.
* - `held Ns` anchors continuity: the inner must be *continuously* `true` for the duration;
* any tick where the inner is `false` **or UNKNOWN** resets the anchor (a flicker restarts
* the clock).
* - `within Nm` anchors at the qualifying event: once the inner was `true`, the window is
* `true` until `N` elapses, then `false`.
* - `nth` counts rising edges of its inner event and latches `true` once the count reaches
* the ordinal.
*
* Memory is keyed by a **structural path** (a fixed tree ⇒ a fixed key per node), so it is
* both deterministic and serializable — {@link TriggerEvaluator.dumpState} returns it for the
* determinism tests; {@link TriggerEvaluator.reset} clears it.
*
* ## Detector / calendar / fill inputs are provider hooks
* `failed-break`-style structural events, `time HH:MM` windows, and fill-lifecycle events
* are inputs from other modules; they resolve through OPTIONAL {@link SeriesProvider} hooks
* and read UNKNOWN (fail-closed, absent-with-reason) when the provider does not supply them.
* `phase` is answered directly from canonical state.
*/
import { assertNever } from "../lang/index.js";
import { UNKNOWN, durationMs, isUnknown } from "./types.js";
const ROOT = "$";
/** The quantifier of a **quantified** operand (`children(any|all).<fact>`), or `null` when the
* operand is not a quantified aggregate. Detected purely structurally (a leading `sel-quant`
* selector, core AST) so the evaluator stays vocabulary-agnostic. */
function quantOf(op) {
if (op.kind !== "series" || op.window !== undefined)
return null;
const head = op.segments[0];
return head?.selector?.kind === "sel-quant" ? head.selector.q : null;
}
// ─────────────────────────────────────────────────────────────────────────────
// The evaluator
// ─────────────────────────────────────────────────────────────────────────────
export class TriggerEvaluator {
mem = new Map();
/** Evaluate `trigger` against `provider` at the injected `now` (RUNTIME §0). Returns
* `true | false | UNKNOWN`; only `true` fires. Advances per-node causal memory. */
evaluate(trigger, provider, now) {
return this.evalNode(trigger, ROOT, provider, now);
}
/** Clear all per-node memory (determinism / re-arm). */
reset() {
this.mem.clear();
}
/** A deterministic dump of the causal memory (sorted, deep-copied). */
dumpState() {
return [...this.mem.entries()]
.map(([path, m]) => [path, { ...m }])
.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
}
// ── the recursion ───────────────────────────────────────────────────────────
evalNode(node, path, provider, now) {
switch (node.kind) {
case "cmp": {
// A quantified operand (`children(any|all).<fact>`) folds the comparator PER MEMBER (∃/∀),
// not against a comparator-blind scalar — otherwise a `<`/`<=` guard inverts which member is
// the deciding one (a fail-open on a natural PM safety guard).
if (quantOf(node.left) !== null || quantOf(node.right) !== null) {
return this.evalQuantifiedCompare(node.op, node.left, node.right, provider, now);
}
const { lv, rv } = this.resolvePair(node.left, node.right, provider, now);
const lU = isUnknown(lv);
const rU = isUnknown(rv);
if (lU && rU)
return UNKNOWN;
if (lU || rU) {
// eq/ne symbolic-literal fallback: a bare unresolved identifier compared against a
// resolved value is taken as a string literal equal to its own name (this is how
// `plan(x).state eq armed` resolves `armed`). Requires the counterpart resolved.
if (node.op === "eq" || node.op === "ne") {
const lFinal = lU ? this.literalOf(node.left) : lv;
const rFinal = rU ? this.literalOf(node.right) : rv;
if (!isUnknown(lFinal) && !isUnknown(rFinal)) {
return node.op === "eq" ? lFinal === rFinal : lFinal !== rFinal;
}
}
return UNKNOWN;
}
return this.compareResolved(node.op, lv, rv);
}
case "cross": {
// A `crosses` is an edge with per-path memory; a quantified aggregate has no single edge to
// remember, so a quantified operand in a cross is meaningless ⇒ fail closed (UNKNOWN).
if (quantOf(node.left) !== null || quantOf(node.right) !== null)
return UNKNOWN;
const { lv, rv } = this.resolvePair(node.left, node.right, provider, now);
if (isUnknown(lv) || isUnknown(rv))
return UNKNOWN; // cannot observe; memory untouched
if (typeof lv !== "number" || typeof rv !== "number")
return UNKNOWN;
const mem = this.crossMem(path);
const cur = lv > rv ? "above" : lv < rv ? "below" : "equal";
// Re-arm gate: once a banded cross fires it is disarmed until the series
// clears back past `right ∓ band` — so a lone spurious tick around the level cannot
// phantom-fire. A bare (band-less) cross stays armed and behaves as before.
if (node.band !== undefined && !mem.armed) {
const width = node.band.value;
const cleared = node.dir === "above" ? lv <= rv - width : lv >= rv + width;
if (cleared)
mem.armed = true;
}
// Map the position to a firing side. Strict `crosses` ignores an exact touch (it holds
// the last non-equal anchor). At-or-touch `touches` counts reaching the level (`>=`/`<=`)
// as the target side, so it fires the instant the series meets the level.
const side = cur === "equal" ? (node.touch === true ? node.dir : null) : cur;
if (side === null)
return false; // strict straddle: hold the last non-equal anchor
const prev = mem.prevSide;
mem.prevSide = side;
if (prev === null)
return false; // never true on first sight
// fire only on a genuine transition INTO the target side (prior other-side sample)
if (!(prev !== side && side === node.dir))
return false;
// Honor the re-arm band: suppress an in-band re-fire; consume the arm on a real fire.
if (node.band !== undefined) {
if (!mem.armed)
return false;
mem.armed = false;
}
return true;
}
case "break-hold": {
const inner = this.evalNode(node.inner, `${path}.0`, provider, now); // always evaluate
const mem = this.heldMem(path);
const dur = durationMs(node.dur.value, node.dur.unit);
if (inner === true) {
if (mem.since === null)
mem.since = now;
return now - mem.since >= dur; // definite false until the duration elapses
}
mem.since = null; // false OR UNKNOWN breaks continuity — reset the anchor
return inner === UNKNOWN ? UNKNOWN : false;
}
case "within": {
const inner = this.evalNode(node.inner, `${path}.0`, provider, now); // always evaluate
const mem = this.withinMem(path);
const dur = durationMs(node.dur.value, node.dur.unit);
if (inner === true)
mem.lastTrue = now;
if (mem.lastTrue !== null)
return now - mem.lastTrue <= dur;
return inner === UNKNOWN ? UNKNOWN : false;
}
case "until":
case "at": {
// kestrel-rtf: the {until,at} thesis temporal envelope is a LANGUAGE-surface addition (a
// postfix sibling of `within`). Evaluate the inner so a buried cross/held/within/nth still
// advances its per-node causal memory every tick (RUNTIME §3), then route the envelope's
// firing decision through the OPTIONAL provider hook — parity with `failed-break`/`time`/`fill`
// (kestrel-22j.21): a provider that does not supply the temporal-envelope capability reads
// UNKNOWN (fail-closed, absent-with-reason, de-armed with a logged reason), never a silent
// true/false. The concrete firing semantics are wired through this seam by a future runtime
// ADR; until then the capability is honestly ABSENT (a disclosed optional hook), not a
// hardcoded dead UNKNOWN the author cannot see.
const inner = this.evalNode(node.inner, `${path}.0`, provider, now);
return provider.temporalEnvelope ? provider.temporalEnvelope(node, inner, now) : UNKNOWN;
}
case "held-stop": {
// A 0DTE time-HELD stop (`EXIT held 90m`) fires a fixed duration after the POSITION was
// acquired — a fact the generic evaluator (which sees the tape, not the book) cannot know.
// The PlanEngine's EXIT path special-cases it against the plan's first-acquire time
// (src/engine/plans.ts). Everywhere else it has no referent (nothing is "held"), so it
// reads UNKNOWN — fail-closed, never a silent fire — exactly like {until,at}.
return UNKNOWN;
}
case "clock-stop": {
// A 0DTE wall-clock stop (`EXIT clockET 15:40`) — semantically `now >= <clock>` ET. The
// engine EXIT path fires it against the session calendar; here (any non-EXIT position) it
// reads UNKNOWN so it can never silently fire outside a managed position.
return UNKNOWN;
}
case "nth": {
const inner = this.evalNode(node.event, `${path}.0`, provider, now); // always evaluate
const mem = this.nthMem(path);
if (inner === true && !mem.prevInner)
mem.count += 1;
mem.prevInner = inner === true;
if (mem.count >= node.ordinal)
return true; // latched
return inner === UNKNOWN ? UNKNOWN : false;
}
case "phase": {
const p = provider.phase();
if (isUnknown(p))
return UNKNOWN;
return p === node.phase;
}
case "time-window": {
if (provider.timeOfDayMinutes === undefined)
return UNKNOWN; // calendar absent
const m = provider.timeOfDayMinutes(now);
if (isUnknown(m))
return UNKNOWN;
const from = node.from ? node.from.hour * 60 + node.from.minute : null;
const to = node.to ? node.to.hour * 60 + node.to.minute : null;
const afterFrom = from === null || m >= from;
const beforeTo = to === null || m < to; // half-open [from, to)
return afterFrom && beforeTo;
}
case "event": {
return provider.structural ? provider.structural(node, now) : UNKNOWN; // detector absent
}
case "fill": {
return provider.fillEvent ? provider.fillEvent(node) : UNKNOWN; // fill telemetry absent
}
case "and": {
const vals = node.terms.map((t, i) => this.evalNode(t, `${path}.${i}`, provider, now));
let anyUnknown = false;
for (const v of vals) {
if (v === false)
return false;
if (isUnknown(v))
anyUnknown = true;
}
return anyUnknown ? UNKNOWN : true;
}
case "or": {
const vals = node.terms.map((t, i) => this.evalNode(t, `${path}.${i}`, provider, now));
let anyUnknown = false;
for (const v of vals) {
if (v === true)
return true;
if (isUnknown(v))
anyUnknown = true;
}
return anyUnknown ? UNKNOWN : false;
}
case "not": {
const v = this.evalNode(node.term, `${path}.0`, provider, now);
if (isUnknown(v))
return UNKNOWN;
return !v;
}
default:
return assertNever(node, "series/trigger evalNode");
}
}
// ── operand resolution ───────────────────────────────────────────────────────
/** Resolve a comparison/cross operand pair, honoring a `Baseline` on either side (a
* baseline resolves against its paired *series* operand's trailing distribution). */
resolvePair(left, right, provider, now) {
if (right.kind === "baseline") {
if (left.kind !== "series")
return { lv: UNKNOWN, rv: UNKNOWN };
return { lv: provider.resolve(left, now), rv: provider.baseline(left, right) };
}
if (left.kind === "baseline") {
if (right.kind !== "series")
return { lv: UNKNOWN, rv: UNKNOWN };
return { lv: provider.baseline(right, left), rv: provider.resolve(right, now) };
}
return {
lv: this.operandValue(left, provider, now),
rv: this.operandValue(right, provider, now),
};
}
operandValue(op, provider, now) {
switch (op.kind) {
case "series":
return provider.resolve(op, now);
case "quantity":
return op.value; // raw magnitude; unit normalization is the registry's job (later phase)
case "baseline":
return UNKNOWN; // a baseline without a paired series operand is meaningless
default:
return assertNever(op, "series/trigger operandValue");
}
}
/** A bare single-segment series operand → its own name as a symbolic string literal; any
* other operand → UNKNOWN. Used only in the eq/ne literal fallback. */
literalOf(op) {
if (op.kind !== "series" || op.window !== undefined || op.segments.length !== 1)
return UNKNOWN;
const seg0 = op.segments[0];
if (seg0 === undefined || seg0.selector !== undefined)
return UNKNOWN;
return seg0.name;
}
/**
* Fold a comparison where exactly one operand is a **quantified** aggregate
* (`children(any|all).<fact>`): resolve the operand to its per-member vector, apply the authored
* comparator to EACH member (preserving operand order), then combine by the quantifier —
* `any` = ∃ (tri-state OR), `all` = ∀ (tri-state AND). This makes the ∃/∀ meaning honor the
* comparator direction, so a `<`/`<=` guard cannot silently invert (the fail-open the scalar fold
* hid). An unresolvable vector (no member published, or an unknown quantified path) ⇒ UNKNOWN,
* de-armed loudly by the provider (RUNTIME §8). Both operands quantified, or the counterpart
* unresolved, ⇒ UNKNOWN (fail closed). `eq`/`ne` fold per member too; only `crosses` (an edge)
* is excluded, handled in the `cross` node.
*/
evalQuantifiedCompare(op, left, right, provider, now) {
const lq = quantOf(left);
const rq = quantOf(right);
if (lq !== null && rq !== null)
return UNKNOWN; // both quantified is not a well-formed guard
const quant = (lq ?? rq);
const qRef = (lq !== null ? left : right);
const other = lq !== null ? right : left;
const values = provider.quantify?.(qRef, now) ?? UNKNOWN;
if (isUnknown(values))
return UNKNOWN;
const threshold = this.operandValue(other, provider, now);
if (isUnknown(threshold))
return UNKNOWN;
let anyTrue = false;
let anyFalse = false;
let anyUnknown = false;
for (const v of values) {
// Preserve operand order: `children(*).x < t` vs `t < children(*).x` are different guards.
const r = lq !== null ? this.compareResolved(op, v, threshold) : this.compareResolved(op, threshold, v);
if (r === true)
anyTrue = true;
else if (isUnknown(r))
anyUnknown = true;
else
anyFalse = true;
}
if (quant === "any")
return anyTrue ? true : anyUnknown ? UNKNOWN : false; // ∃
return anyFalse ? false : anyUnknown ? UNKNOWN : true; // ∀
}
compareResolved(op, lv, rv) {
switch (op) {
case "eq":
return lv === rv;
case "ne":
return lv !== rv;
case "gt":
case "ge":
case "lt":
case "le": {
if (typeof lv !== "number" || typeof rv !== "number")
return UNKNOWN; // ordering needs numbers
return op === "gt" ? lv > rv : op === "ge" ? lv >= rv : op === "lt" ? lv < rv : lv <= rv;
}
default:
return assertNever(op, "series/trigger compareResolved");
}
}
// ── memory accessors (create-on-first-touch, kind-checked) ────────────────────
crossMem(path) {
const m = this.mem.get(path);
if (m !== undefined) {
if (m.kind !== "cross")
throw new Error(`series/trigger: memory kind clash at ${path}`);
return m;
}
const fresh = { kind: "cross", prevSide: null, armed: true };
this.mem.set(path, fresh);
return fresh;
}
heldMem(path) {
const m = this.mem.get(path);
if (m !== undefined) {
if (m.kind !== "held")
throw new Error(`series/trigger: memory kind clash at ${path}`);
return m;
}
const fresh = { kind: "held", since: null };
this.mem.set(path, fresh);
return fresh;
}
withinMem(path) {
const m = this.mem.get(path);
if (m !== undefined) {
if (m.kind !== "within")
throw new Error(`series/trigger: memory kind clash at ${path}`);
return m;
}
const fresh = { kind: "within", lastTrue: null };
this.mem.set(path, fresh);
return fresh;
}
nthMem(path) {
const m = this.mem.get(path);
if (m !== undefined) {
if (m.kind !== "nth")
throw new Error(`series/trigger: memory kind clash at ${path}`);
return m;
}
const fresh = { kind: "nth", count: 0, prevInner: false };
this.mem.set(path, fresh);
return fresh;
}
}