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.
361 lines (360 loc) • 22.6 kB
JavaScript
/**
* # grade/receipts — the pure PROJECTOR that lifts agent receipts off a Session bus (kestrel-a57.5)
*
* `projectReceipts(bus)` is the deterministic fold from one canonical Session {@link ../bus Bus} into the
* {@link SessionReceipts} curation record — the immutable set of every agent PROPOSAL, DECLINE, and
* STAND-DOWN taken in the session. It mirrors the {@link ../blotter Blotter} projector's discipline
* (ADR-0011): **a pure function of the bus** — same bus in ⇒ byte-identical receipts out, no engine-state
* scrape, no wall clock, no RNG.
*
* **Curation honesty (the a57.5 doctrine).** The projector records EVERY decision, not only the armed ones.
* A survivor-only record cannot grade curation, so declines and stand-downs are MANDATORY rows — the record
* carries the plans the agent declined and stood down, alongside the plans it armed.
*
* **How a decision rides the bus (today, and the seam).** An agent decision is carried on the existing
* **CONTROL** stream — the L2→L1 author-intent channel, whose `type` vocabulary is open (bus/types) — with
* `type ∈ {propose, decline, stand_down}`, `target` naming the plan, and the structured
* {@link DecisionDescriptor} (thesis axes, counterfactual, measurement versions, plan source) carried as
* canonical JSON in the CONTROL `note`. This keeps the bus schema UNCHANGED. The note-as-JSON transport is a
* documented SEAM: when a typed proposal envelope lands (a57.14) the descriptor moves onto a typed payload,
* but the projector's contract — receipts are a pure projection of the bus — is unchanged. The **percept**
* the agent acted on is projected today as the canonical bus PREFIX (`seq < proposed_seq`); the percept-v5
* {@link ../frame Frame} is the future enrichment (see {@link ./receipt.ts} `PerceptProjection`).
*
* **Fail closed (AGENTS.md).** A CONTROL decision whose `note` is missing/malformed is not silently dropped:
* it is recorded in {@link SessionReceipts.skipped} with a logged reason (a corruption cannot masquerade as a
* well-formed decision, and it cannot fabricate a thesis). A well-formed decision whose PLAN text fails to
* parse degrades to a STAND-DOWN receipt (plan = null) whose rationale records the escape — the curation row
* is kept ("parse escape → STAND_DOWN").
*/
import { serializeBus } from "../bus/index.js";
import { canonicalizeJson } from "../canonical/json.js";
import { ARMING_DECISIONS, COUNTERFACTUAL_KINDS, DECISION_KINDS, PlanParseError, ReceiptValidationError, createReceipt, sha256, } from "./receipt.js";
// ─────────────────────────────────────────────────────────────────────────────
// The bus transport (CONTROL note-JSON) — a documented seam
// ─────────────────────────────────────────────────────────────────────────────
/** The CONTROL `type`s the projector reads as agent decisions. Other CONTROL actions (arm/disarm/author/
* supersede/allocate) are ignored — they are not decisions with a thesis. */
export const DECISION_CONTROL_TYPES = new Set(["propose", "decline", "stand_down"]);
/** The projector that produces the percept-hash bytes today (the bus prefix). Pins meaning across
* revisions; becomes `{ kind: "frame", ... }` when the percept-v5 Frame lands (a documented seam). */
const BUS_PREFIX_PROJECTION = { kind: "bus-prefix", renderer_version: "bus-prefix-v0" };
/** Encode a decision descriptor as the canonical-JSON string a CONTROL `note` carries. The inverse of the
* projector's decode; exported so a harness (and tests) write the exact bytes the projector reads. */
export function encodeDecision(d) {
return JSON.stringify(d);
}
// ─────────────────────────────────────────────────────────────────────────────
// The PRODUCER (kestrel-a57.18) — the driver-side half that WRITES what the projector reads
// ─────────────────────────────────────────────────────────────────────────────
/**
* The thesis a DRIVER-authored decision carries today: **UNSTATED**.
*
* The receipt schema demands a thesis on every row, but the Session action surface
* ({@link import("../session/agent.ts").Action}) carries no structured thesis — an agent states its
* intent as free-text `journal`/`note` prose, never as the gradable axes. The producer therefore states
* NOTHING on the agent's behalf: every axis is `null` (no claim), `conviction` is `0` (the floor — the
* receipt asserts no confidence the agent did not express), and the day-type distribution is the single
* explicit `unstated` mass. This is the honest encoding of "no thesis was stated", never a plausible
* thesis invented by the runtime (a fabricated claim would be exactly the "silently false" the fail-closed
* doctrine forbids, and it would poison the very clustering the axes exist to enable).
*
* **Seam (a57.14).** When the typed proposal envelope lands, the agent states its OWN axes and they ride
* here instead. A grader can tell the two apart mechanically: `day_type_probabilities.unstated === 1`.
*/
export const UNSTATED_THESIS = {
when: { session_phase: null, regime: null, event: null, trend_state: null, volatility_state: null },
where: { product: null, direction: null, strike: null, moneyness: null, book_state: null },
how: { entry_method: null, size_contracts: null, reload: null, invalidation: null, exit: null },
conviction: 0,
day_type_probabilities: { unstated: 1 },
};
/** The commission model this engine applies to a simulated fill: **none**. A truthful, checkable pin —
* the sim charges no commission, so a grader must not compare its EV against a commissioned run. */
export const DRIVER_COMMISSION_MODEL = "none-v0";
/** The bounded-risk envelope version the engine enforces (`budget NR` + the never-naked/intrinsic-floor
* admission Gate). Bump when the envelope's arithmetic changes — EVs across versions refuse comparison. */
export const DRIVER_RISK_ENVELOPE = "budget-r-v1";
/** The grader version a receipt minted by this engine is measured by (the headline grader). */
export const DRIVER_GRADER = "grade-v1";
/**
* Pin the measurement versions for a decision minted by the Session driver. Pure: a function of the run's
* judge (`fill_model`) and the bus's fidelity only — no clock, no RNG. Everything a version could move a
* number through is pinned, so a grade can refuse to compare EVs across versions (ADR-0006).
*/
export function driverMeasurementVersions(judge) {
return {
fill_model: judge.fillModel,
commission_model: DRIVER_COMMISSION_MODEL,
bus_fidelity: judge.fidelity,
risk_envelope: DRIVER_RISK_ENVELOPE,
grader: DRIVER_GRADER,
};
}
/**
* Build the CONTROL event FIELDS that carry one agent decision receipt onto the Session bus — the
* producer half of the a57.5 seam (kestrel-a57.18). The driver spreads the result into its own `emit`
* (which stamps `ts`/`seq`), so the transport stays the unchanged CONTROL stream and the projector's
* contract — receipts are a pure projection of the bus — is untouched.
*
* Pure and deterministic: same input ⇒ byte-identical `note`. It performs NO validation of its own — the
* projector is the single validator (one definition, never a second one), and it fails closed on anything
* this writes that does not cohere.
*/
export function decisionControlFields(d) {
const descriptor = {
decision_kind: d.decision_kind,
plan_text: d.plan_text,
counterfactual: d.counterfactual ?? "null",
measurement_versions: d.measurement_versions,
thesis: d.thesis ?? UNSTATED_THESIS,
...(d.rationale !== undefined ? { rationale: d.rationale } : {}),
};
return {
type: d.control,
...(d.target !== undefined ? { target: d.target } : {}),
note: encodeDecision(descriptor),
};
}
// ─────────────────────────────────────────────────────────────────────────────
// Decode helpers (fail-closed)
// ─────────────────────────────────────────────────────────────────────────────
/** Parse a CONTROL `note` into a {@link DecisionDescriptor}, or throw with a precise reason. Total. */
function decodeDescriptor(note) {
if (note === undefined || note.length === 0)
throw new ReceiptValidationError("CONTROL decision carries no note payload");
let raw;
try {
raw = JSON.parse(note);
}
catch (err) {
throw new ReceiptValidationError(`CONTROL decision note is not valid JSON: ${err.message}`);
}
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
throw new ReceiptValidationError("CONTROL decision note must be a JSON object");
}
const d = raw;
if (typeof d.decision_kind !== "string" || !DECISION_KINDS.has(d.decision_kind)) {
throw new ReceiptValidationError("decision descriptor decision_kind is missing or unknown");
}
if (typeof d.counterfactual !== "string" || !COUNTERFACTUAL_KINDS.has(d.counterfactual)) {
throw new ReceiptValidationError("decision descriptor counterfactual is missing or unknown");
}
if (d.plan_text !== null && typeof d.plan_text !== "string") {
throw new ReceiptValidationError("decision descriptor plan_text must be a string or null");
}
if (typeof d.measurement_versions !== "object" || d.measurement_versions === null) {
throw new ReceiptValidationError("decision descriptor measurement_versions is missing");
}
if (typeof d.thesis !== "object" || d.thesis === null) {
throw new ReceiptValidationError("decision descriptor thesis is missing");
}
return d;
}
/** The decision kinds the given CONTROL `type` admits (fail-closed pairing between coarse marker and fine
* kind). Prevents a `propose` note that secretly declares a `decline`, etc. */
function admissibleKinds(controlType) {
switch (controlType) {
case "propose":
return ARMING_DECISIONS;
case "decline":
return new Set(["decline"]);
case "stand_down":
return new Set(["stand_down"]);
default:
return new Set();
}
}
// ─────────────────────────────────────────────────────────────────────────────
// projectReceipts
// ─────────────────────────────────────────────────────────────────────────────
/** The opening META of the bus, or a loud refusal (fail-closed): a well-formed bus opens with exactly one
* META. Unlike the Blotter projector this does NOT require a GRADED bus — a receipt is authored on the input
* tape, before any fill model grades it. */
function requireMeta(bus) {
const meta = bus.find((e) => e.stream === "META");
if (meta === undefined) {
throw new ReceiptValidationError("no META header in bus (a well-formed bus opens with exactly one META)");
}
return meta;
}
/**
* Project one Session bus into its immutable {@link SessionReceipts} curation record. Pure and deterministic
* (ADR-0011): no wall clock, no RNG, no engine-state scrape — the SAME bus yields byte-identical receipts
* (proven by re-projecting and byte-comparing {@link serializeReceipts}). Records every armed/declined/
* stood-down decision (curation honesty); records malformed decisions in `skipped` (fail-closed).
*/
export function projectReceipts(bus) {
const meta = requireMeta(bus);
const session = { date: meta.session_date, mode: meta.mode };
const receipts = [];
const skipped = [];
/** Plan names an arming RECEIPT claims (the `target` of a decision that projected to an arming kind). */
const armedByReceipt = new Set();
for (const e of bus) {
if (e.stream !== "CONTROL")
continue;
const ce = e;
if (!DECISION_CONTROL_TYPES.has(ce.type))
continue; // not a decision (arm/disarm/author/…)
try {
const d = decodeDescriptor(ce.note);
if (!admissibleKinds(ce.type).has(d.decision_kind)) {
throw new ReceiptValidationError(`CONTROL type ${JSON.stringify(ce.type)} is incompatible with decision_kind ${JSON.stringify(d.decision_kind)}`);
}
// Percept — the canonical bus PREFIX the agent could have perceived (seq strictly before this
// decision). This is the AVAILABLE representation today; the percept-v5 Frame is the seam.
// The bus is append-only in monotonic `seq` order, so the prefix's last element is its watermark.
const prefix = bus.filter((p) => p.seq < ce.seq);
if (prefix.length === 0) {
throw new ReceiptValidationError("decision precedes the session META (no percept prefix)");
}
const lastPerceived = prefix[prefix.length - 1];
const prefixBytes = serializeBus(prefix);
const percept = {
sha256: sha256(prefixBytes),
byte_count: Buffer.byteLength(prefixBytes, "utf8"),
seq_watermark: lastPerceived.seq,
projection: BUS_PREFIX_PROJECTION,
};
// The claimed information cutoff can never exceed the watermark of the percept the receipt pins:
// the percept is the bus prefix (seq < proposed_seq), whose latest information is lastPerceived.ts.
// A cutoff beyond it is a look-ahead provenance claim the pinned percept cannot back — fail closed.
if (d.information_cutoff !== undefined && d.information_cutoff > lastPerceived.ts) {
throw new ReceiptValidationError(`information_cutoff ${d.information_cutoff} exceeds the pinned percept's watermark ts ${lastPerceived.ts} ` +
`(look-ahead: the decision claims information more recent than the percept it hashed)`);
}
const information_cutoff = d.information_cutoff ?? lastPerceived.ts;
// "parse escape → STAND_DOWN": a well-formed decision whose plan text does not parse degrades to a
// stand-down receipt (plan = null) — the curation row is KEPT, with the escape recorded in rationale.
let receipt;
try {
receipt = createReceipt({
session_date: meta.session_date,
proposed_at: ce.ts,
proposed_seq: ce.seq,
information_cutoff,
decision_kind: d.decision_kind,
percept,
plan_text: d.plan_text,
counterfactual: d.counterfactual,
measurement_versions: d.measurement_versions,
thesis: d.thesis,
rationale: d.rationale ?? null,
});
}
catch (err) {
if (err instanceof PlanParseError && d.plan_text !== null) {
receipt = createReceipt({
session_date: meta.session_date,
proposed_at: ce.ts,
proposed_seq: ce.seq,
information_cutoff,
decision_kind: "stand_down",
percept,
plan_text: null,
counterfactual: d.counterfactual,
measurement_versions: d.measurement_versions,
thesis: d.thesis,
rationale: `parse escape → stand-down: ${err.message}`,
});
}
else {
throw err;
}
}
receipts.push(receipt);
// Bind this arming receipt to the plan NAME it claims to have armed, for reconciliation against the
// bus's real arm record. A decision that degraded to stand-down (parse escape) is NOT an arm.
if (ARMING_DECISIONS.has(receipt.decision_kind) && ce.target !== undefined) {
armedByReceipt.add(ce.target);
}
}
catch (err) {
const reason = err instanceof Error ? err.message : String(err);
skipped.push({ seq: ce.seq, reason });
}
}
receipts.sort((a, b) => a.proposed_seq - b.proposed_seq);
skipped.sort((a, b) => a.seq - b.seq);
const curation = assessCuration(bus, receipts, armedByReceipt);
return { session, receipts, skipped, curation };
}
// ─────────────────────────────────────────────────────────────────────────────
// Curation-honesty verdict — survivor-only detection + arm reconciliation
// ─────────────────────────────────────────────────────────────────────────────
/** The plan names the bus's real ARM record armed: every `PLAN` armed transition and every `CONTROL arm`
* with a `target`. This is the execution-side arm set the receipt channel must reconcile with — the same
* canonical arm path that drives the {@link ../blotter Blotter}. Sorted, deduplicated. */
function armedOnBus(bus) {
const armed = new Set();
for (const e of bus) {
if (e.stream === "PLAN" && e.type === "lifecycle" && e.state === "armed")
armed.add(e.plan);
else if (e.stream === "CONTROL" && e.type === "arm") {
const t = e.target;
if (t !== undefined)
armed.add(t);
}
}
return [...armed].sort();
}
/**
* Compute the {@link CurationVerdict} for a projected record (findings: survivor-only records and the
* receipt/arm decoupling). Fail-closed and structural, NOT an honor system:
* • **survivor-only** — arming receipts with zero declines/stand-downs cannot grade curation; an empty
* record has nothing to grade. Either makes the record non-gradable.
* • **arm reconciliation** — when the bus carries a real arm record, the arming receipts and the bus's
* arm set must AGREE: an arm with no receipt hides curation; a receipt claiming an arm the bus never
* armed is a self-reported fiction. Either divergence makes the record non-gradable.
* Pure and deterministic — a function of the bus and the receipts only.
*/
function assessCuration(bus, receipts, armedByReceipt) {
const armed = receipts.filter((r) => ARMING_DECISIONS.has(r.decision_kind)).length;
const declined_or_stood_down = receipts.length - armed;
const busArms = armedOnBus(bus);
const arm_record_present = busArms.length > 0;
const busArmSet = new Set(busArms);
const unreceipted_arms = busArms.filter((name) => !armedByReceipt.has(name));
const phantom_arms = arm_record_present ? [...armedByReceipt].filter((name) => !busArmSet.has(name)).sort() : [];
const reasons = [];
if (receipts.length === 0) {
reasons.push("no decisions recorded — nothing to grade (curation requires declines/stand-downs)");
}
else if (declined_or_stood_down === 0) {
reasons.push(`survivor-only: ${armed} arming decision(s) with no declines or stand-downs recorded — ` +
`a survivor-only record cannot distinguish a good selector from a lucky one (declines/stand-downs are MANDATORY)`);
}
if (unreceipted_arms.length > 0) {
reasons.push(`arm record diverges: ${unreceipted_arms.length} plan(s) armed on the bus have no arming receipt ` +
`[${unreceipted_arms.join(", ")}] — the engine armed what the receipt record does not own`);
}
if (phantom_arms.length > 0) {
reasons.push(`arm record diverges: ${phantom_arms.length} arming receipt(s) name a plan the bus never armed ` +
`[${phantom_arms.join(", ")}] — a self-reported arm the execution log does not back`);
}
return {
gradable: reasons.length === 0,
reasons,
armed,
declined_or_stood_down,
arm_record_present,
unreceipted_arms,
phantom_arms,
};
}
// ─────────────────────────────────────────────────────────────────────────────
// Canonical serialization
// ─────────────────────────────────────────────────────────────────────────────
/** Serialize a {@link SessionReceipts} record to its canonical bytes: deterministic machine-JSON
* (lexicographically ordered keys, compact, one trailing newline). Pure and total — the same record always
* produces byte-identical text, the substrate a determinism check byte-compares. The key order is the one
* {@link canonicalizeJson canonical byte order} (canonical/json); the single trailing newline is canon. */
export function serializeReceipts(record) {
return canonicalizeJson(record) + "\n";
}
/** The declined and stood-down receipts (the curation the agent left on the table) — the non-arming
* decisions a survivor-only record would drop. A convenience over {@link SessionReceipts.receipts}. */
export function declinedAndStoodDown(record) {
return record.receipts.filter((r) => !ARMING_DECISIONS.has(r.decision_kind));
}