UNPKG

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
/** * # 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; } }