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
35 changes: 33 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ the Development section below is how to provision them.

```ts
import { compile, decompile, durationToMilliseconds, evaluate, execute, executeValue, float, isaVersion, parse, parseDuration, toHost } from "@riddler/predicator";
import { decodeTagged, encodeTagged, evaluateTagged } from "@riddler/predicator/tagged";
import { decodeTagged, encodeTagged, evaluateTagged, executeTagged } from "@riddler/predicator/tagged";
```

- **`@riddler/predicator`** is the main entry point: the value domain, the host
Expand All @@ -80,7 +80,8 @@ import { decodeTagged, encodeTagged, evaluateTagged } from "@riddler/predicator/
back to source text, the reading of a duration from its literal spelling
and its length in milliseconds, and the version of the instruction set this build implements.
- **`@riddler/predicator/tagged`** is the tagged-value subpath: a codec for the
conformance corpus's tagged encoding, and the one evaluation that speaks it.
conformance corpus's tagged encoding, and the one evaluation and the one
statement run that speak it.
That encoding carries the members a plain JSON round trip loses - a date, a
datetime, a duration, an absence, and the difference between an integer and
an integral float. The main entry point neither emits nor requires it.
Expand Down Expand Up @@ -775,6 +776,36 @@ if (!signedUpAt.ok || signedUpAt.value !== '{"$type":"datetime","value":"2026-03
}
```

`executeTagged` is the statement run beside it. `execute` hands the context a
program halted with back under the plain projection, which drops a float's
brand; `executeTagged` answers that context as the encoding's text on both
arms, so `decodeTagged` reads back every value the program bound - a float the
program stored stays a float, and a partial context on the failing arm reads
back the same way. It takes a compiled program or source text, which it
compiles as `compileProgram` does, and the main entry point's options: the
encoding is the only form it answers in, so there is no `tagged` request to
make. A context the encoding cannot carry is a failure rather than a throw:
on the successful arm it is the encoder's reason, and on the failing arm the
run's own error stays the answer and the partial context is left off.

```ts
import { isFloat } from "@riddler/predicator";
import { decodeTagged, executeTagged } from "@riddler/predicator/tagged";

// A library loan's overdue script that sets a flat late fee.
const run = executeTagged("late_fee = 2.0", { loan: { days_late: 3 } });

if (!run.ok || run.context !== '{"loan":{"days_late":3},"late_fee":2.0}') {
throw new Error("the halt context comes back as the encoding's text");
}

const back = decodeTagged(run.context);

if (!back.ok || !isFloat((back.value as { late_fee: unknown }).late_fee)) {
throw new Error("the late fee reads back as the float the script stored");
}
```

`decodeTagged` and `encodeTagged` are the codec itself, for a host that
persists a value rather than evaluating one. Every failure the codec names a
reason for is answered as the failing arm of a result rather than thrown, and a
Expand Down
3 changes: 3 additions & 0 deletions changelog.d/pts-mmls.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
### Added

- `executeTagged` on the `./tagged` subpath runs a statement program, from a compiled list or from source text, and answers the context it halted with as the tagged encoding's text on both arms, so `decodeTagged` reads every value back as the one the program bound: a float the program stored stays a float, where `execute`'s plain projection answers the integer.
42 changes: 42 additions & 0 deletions docs/adr/0002-the-value-domain-and-the-host-boundary.md
Original file line number Diff line number Diff line change
Expand Up @@ -3572,3 +3572,45 @@ reason token is added, and no opcode and no wire-format change follow. The
compiled duration literal written with such a component answers when it is
evaluated, so a component written past the bound is now refused by the
literal, the cast and the export alike.

## Note: the subpath's second entry point, the statement run that answers the encoding (2026-10-01)

Recorded for `pts-mmls`, on the ruling that the statement run speaking the
tagged encoding rides this release (ruled by the operator, 2026-10-01). This
note is appended, and removes no line above. It decides nothing: it names a
function that a change adds, says which accepted sentences it falls under, and
says how each still reads. Code on the default branch is cited as read at
`929bcb7`; `executeTagged` in `src/tagged.ts` is the function this same change
adds.

**`executeTagged` in `src/tagged.ts` is an entry point in the sense the note
"the projection is an export and is not an entry point for host input"
defines: a function that runs a program and answers its result.** That note's
sentence opening "Those are `evaluate`, `execute` and `executeValue` in
`src/index.ts` and `evaluateTagged` in `src/tagged.ts`" listed every such
function when it was written; with this change the list has a fifth member,
and `executeTagged` is the `./tagged` subpath's second entry point. Its result
type has a failing arm, so a refusal is sayable there as at the other four.
It projects nothing: it hands the context it ran to to `encodeTagged` in
`src/tagged.ts`, which refuses a cyclic value, one nesting past the depth limit
and one past the place budget onto its own failing arm rather than raising, so
the note's paragraph on what the projection is handed gains no new caller.

**`tagged` is still accepted by `evaluateTagged` alone.** The Decision's
paragraph saying the request "is accepted by the entry point that subpath
exports and by no other", and the note on the Consequences paragraph naming
"the subpath's entry point, `evaluateTagged` in `src/tagged.ts`", each read a
single entry point where there are now two. Both stay true of the option:
`executeTagged` takes `EvaluateOptions`, not `TaggedEvaluateOptions`, accepts
no `tagged` member, and always answers the encoding, so the option is accepted
at `evaluateTagged` and at no other function on either entry point.

**This is the change the amendment on the three questions the statement-mode
note holds anticipated.** That amendment's paragraph on the integer/float distinction left open whether the
subpath gains a statement mode, and said that "a change that gives it one owes
the encoding across the returned context to a caller that needs the
distinction to survive, and leaves the main entry point's returned context as
the plain projection". `executeTagged` answers the context it halted with as
the encoding's text on both arms, the failing arm's partial context included,
and `execute` and `executeValue` in `src/index.ts` still answer the plain
projection. The obligation is discharged by this change and by nothing else.
4 changes: 3 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,9 @@ export function evaluate(
* The context comes back as a plain object of projected values, under the same
* projection `evaluate` applies to a result, so it carries the same documented
* loss: a float comes back as a plain number with the brand gone, and a host
* that means a float when it feeds one back writes `float()`.
* that means a float when it feeds one back writes `float()`. A host that
* needs the brand kept on the way out calls `executeTagged` on `./tagged`,
* which answers this context as the tagged encoding's text.
*
* The caller's own context is never written into. A run answers a new context,
* so a caller that wants all-or-nothing on failure ignores what comes back and
Expand Down
102 changes: 96 additions & 6 deletions src/tagged.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@
* integer or in floating-point form, and that is a distinction that has
* already been destroyed by the time a parsed number is in hand.
*
* This subpath also carries the one evaluation that speaks the encoding. The
* main entry point neither emits nor requires it, so the request for it is a
* member of this subpath's options type and of no other - a request for it at
* the main entry point is refused by the compiler rather than at run time.
* This subpath also carries the one evaluation that speaks the encoding, and
* the one statement run that does. The main entry point neither emits nor
* requires it, so the request for it is a member of this subpath's options
* type and of no other - a request for it at the main entry point is refused
* by the compiler rather than at run time. The statement run takes no such
* request, because answering the encoding is the only thing it adds.
*
* The encoding is the corpus's apparatus, specified by predicator-ex's
* `conformance/README.md`. Offering a codec for it here does not promote it:
Expand All @@ -25,8 +27,15 @@
* nor requires it.
*/

import { EvaluationError } from "./errors.js";
import { type EvaluateOptions, evaluateToValue, type ProjectedEvaluation } from "./evaluator.js";
import { compileProgram } from "./compile.js";
import type { Context } from "./context.js";
import { EvaluationError, type ParseError, type PredicatorError } from "./errors.js";
import {
type EvaluateOptions,
evaluateToValue,
executeToContext,
type ProjectedEvaluation,
} from "./evaluator.js";
import { floatMagnitude, floatText } from "./floats.js";
import type { Program } from "./instructions.js";
import { formatDate, formatDateTime, isCivilDate } from "./iso.js";
Expand Down Expand Up @@ -714,3 +723,84 @@ export function evaluateTagged(
error: new EvaluationError(encoded.reason, "the result is outside the tagged encoding"),
};
}

/**
* What `executeTagged` answers: the context at halt, as the text of the
* corpus's tagged-value encoding.
*
* Its arms are `execute`'s, with the context written as text rather than
* projected. The failing arm admits a `ParseError` because a source that does
* not compile never runs, and that arm carries no context: there is nothing it
* bound.
*/
type TaggedExecution =
| { readonly ok: true; readonly context: string }
| {
readonly ok: false;
readonly error: PredicatorError | ParseError;
readonly context?: string;
};

/**
* Runs a STATEMENT program and answers the context it halted with, as the text
* of the corpus's tagged-value encoding, from a compiled instruction list or
* from source text.
*
* It is the statement run beside `evaluateTagged`. The main entry point's
* `execute` hands the context back under the plain projection, which drops a
* float's brand: a program that stores the float two reads back the integer
* two there. Here the context is encoded before anything projects it, so
* `decodeTagged` reads every value back as the one the program bound - an
* integral float a float, and a date, a datetime, a duration and an absence
* as themselves.
*
* A string is compiled as a statement program, as `compileProgram` compiles
* it, and run exactly as that instruction list is. A source that does not
* compile comes back on the failing arm as the `ParseError` `compileProgram`
* answers, never as a throw, and with no context.
*
* The run is `execute`'s: the same options, the same budgets and the same
* refusals, and the caller's own context is never written into. The failing
* arm carries the context as far as the program got - every write completed
* before the failing statement - in the same encoding, and carries none when
* the value boundary refused the context before any program ran.
*
* It takes the main entry point's options and not this subpath's, because the
* encoding is not a request here: it is the only form this answers in. A
* context the encoding cannot carry is a failure, not a throw. On the
* successful arm that failure is the encoder's reason, with no context; on
* the failing arm the run's own error stays the answer, and the context it
* cannot write is left off.
*/
export function executeTagged(
program: Program | string,
context?: unknown,
options?: EvaluateOptions,
): TaggedExecution {
let instructions: Program;
if (typeof program === "string") {
const compiled = compileProgram(program);
if (!compiled.ok) return { ok: false, error: compiled.error };
instructions = compiled.instructions;
} else {
instructions = program;
}
const outcome = executeToContext(instructions, context, options);
if (outcome.ok) {
const encoded = encodeContext(outcome.context);
if (encoded.ok) return { ok: true, context: encoded.text };
return {
ok: false,
error: new EvaluationError(encoded.reason, "the context is outside the tagged encoding"),
};
}
if (outcome.context === undefined) return { ok: false, error: outcome.error };
const encoded = encodeContext(outcome.context);
if (!encoded.ok) return { ok: false, error: outcome.error };
return { ok: false, error: outcome.error, context: encoded.text };
}

/** Encodes a context as the map of its roots, in the value domain. */
function encodeContext(context: Context): EncodeResult {
return encodeTagged(context.asMap());
}
3 changes: 2 additions & 1 deletion test/export-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
"TaggedEvaluateOptions",
"decodeTagged",
"encodeTagged",
"evaluateTagged"
"evaluateTagged",
"executeTagged"
]
}
130 changes: 129 additions & 1 deletion test/tagged.test.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
import { describe, expect, it } from "vitest";
import type { EvaluateOptions } from "../src/index.js";
import { compileProgram, type EvaluateOptions } from "../src/index.js";
import {
type DecodeReason,
decodeTagged,
type EncodeReason,
encodeTagged,
evaluateTagged,
executeTagged,
type TaggedEvaluateOptions,
} from "../src/tagged.js";
import {
Duration,
Float,
float,
fromHost,
isFloat,
PDate,
PDateTime,
toHost,
Expand Down Expand Up @@ -716,3 +718,129 @@ describe("evaluateTagged", () => {
expect(evaluateTagged([["load", "cohort"]], {}, options).ok).toBe(false);
});
});

describe("executeTagged", () => {
/** The map a context's text decodes to. Fails loudly rather than defaulting. */
function decodedContext(text: string | undefined): { [key: string]: Value } {
if (text === undefined) throw new Error("expected a context, got none");
const value = decoded(text);
if (value === null || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`expected a map, got ${text}`);
}
return value as { [key: string]: Value };
}

// Sabotage: projecting the halt context before encoding it, as `execute`
// does, turns this red: the late fee comes back as the integer two. So does
// compiling the source as an expression rather than a statement program,
// which refuses the assignment. Both were run and reverted.
it("keeps an integral float a float on the success arm, from source text", () => {
const outcome = executeTagged("late_fee = 2.0");
expect(outcome).toEqual({ ok: true, context: '{"late_fee":2.0}' });
if (!outcome.ok) return;
const lateFee = decodedContext(outcome.context).late_fee;
expect(isFloat(lateFee)).toBe(true);
expect(lateFee).toEqual(float(2));
});

it("runs a compiled program the same way it runs its source", () => {
const compiled = compileProgram("late_fee = 2.0; total = 2");
expect(compiled.ok).toBe(true);
if (!compiled.ok) return;
const outcome = executeTagged(compiled.instructions, { loan: { days_late: 10 } });
expect(outcome).toEqual({
ok: true,
context: '{"loan":{"days_late":10},"late_fee":2.0,"total":2}',
});
});

// Sabotage: projecting the partial context before encoding it turns this
// red: the late fee written before the refusal comes back as the integer
// two. It was run and reverted.
it("answers the partial context the same way on the failure arm", () => {
const outcome = executeTagged(
"late_fee = 2.0; loan.days_late = 3",
{ loan: { days_late: 10 } },
{
protectedRoots: ["loan"],
},
);
expect(outcome.ok).toBe(false);
if (outcome.ok) return;
expect(outcome.error.type).toBe("EvaluationError");
expect(outcome.error.reason).toBe("protected_root");
expect(outcome.context).toBe('{"loan":{"days_late":10},"late_fee":2.0}');
expect(isFloat(decodedContext(outcome.context).late_fee)).toBe(true);
});

it("carries the dates, durations and absences a plain projection loses", () => {
const outcome = executeTagged("due_on = #2026-03-01#; grace = 3d; hold = missing");
expect(outcome.ok).toBe(true);
if (!outcome.ok) return;
const context = decodedContext(outcome.context);
expect(context.due_on).toEqual(new PDate(2026, 3, 1));
expect(context.grace).toEqual(new Duration({ days: 3 }));
expect(context.hold).toBe(Undefined);
});

it("answers the loop budget's refusal with the context the run reached", () => {
const outcome = executeTagged(
"renewals = 0; while (true) { renewals = renewals + 1 }",
{},
{
loopBudget: 3,
},
);
expect(outcome.ok).toBe(false);
if (outcome.ok) return;
expect(outcome.error.reason).toBe("loop_budget_exceeded");
expect(decodedContext(outcome.context).renewals).toBeTypeOf("number");
});

it("answers a source the program compiler refuses as its parse error, with no context", () => {
const outcome = executeTagged("late_fee = ");
expect(outcome.ok).toBe(false);
if (outcome.ok) return;
expect(outcome.error.type).toBe("ParseError");
expect("context" in outcome).toBe(false);
});

it("answers a context the value boundary refuses with no context", () => {
const outcome = executeTagged("late_fee = 2.0", { loan: Number.NaN });
expect(outcome.ok).toBe(false);
if (outcome.ok) return;
expect(outcome.error.type).toBe("EvaluationError");
expect("context" in outcome).toBe(false);
});

// Sabotage: letting the encoder's refusal through as a successful context
// turns this red. It was run and reverted.
it("answers a halt context the encoding cannot carry as a failure", () => {
const outcome = executeTagged("late_fee = 2.0", { slip: { $type: "date" } });
expect(outcome.ok).toBe(false);
if (outcome.ok) return;
expect(outcome.error.type).toBe("EvaluationError");
expect(outcome.error.reason).toBe("reserved_map_key");
expect("context" in outcome).toBe(false);
});

// Sabotage: handing back an empty text as the context the encoding refused
// turns this red. It was run and reverted.
it("keeps the run's own refusal when its partial context cannot be encoded", () => {
const outcome = executeTagged(
"late_fee = 2.0; loan.days_late = 3",
{ loan: { days_late: 10 }, slip: { $type: "date" } },
{ protectedRoots: ["loan"] },
);
expect(outcome.ok).toBe(false);
if (outcome.ok) return;
expect(outcome.error.reason).toBe("protected_root");
expect("context" in outcome).toBe(false);
});

it("never writes into the caller's context", () => {
const context = { loan: { days_late: 10 } };
executeTagged("loan.days_late = 2.0", context);
expect(context).toEqual({ loan: { days_late: 10 } });
});
});
Loading