diff --git a/README.md b/README.md index 1dc3334..2f4a5c5 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. @@ -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 diff --git a/changelog.d/pts-mmls.md b/changelog.d/pts-mmls.md new file mode 100644 index 0000000..2986d71 --- /dev/null +++ b/changelog.d/pts-mmls.md @@ -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. diff --git a/docs/adr/0002-the-value-domain-and-the-host-boundary.md b/docs/adr/0002-the-value-domain-and-the-host-boundary.md index 9e363db..f793576 100644 --- a/docs/adr/0002-the-value-domain-and-the-host-boundary.md +++ b/docs/adr/0002-the-value-domain-and-the-host-boundary.md @@ -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. diff --git a/src/index.ts b/src/index.ts index b4e80ab..6bd2d86 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 diff --git a/src/tagged.ts b/src/tagged.ts index 1a08663..9934e1e 100644 --- a/src/tagged.ts +++ b/src/tagged.ts @@ -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: @@ -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"; @@ -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()); +} diff --git a/test/export-surface.json b/test/export-surface.json index 3627220..0f24e11 100644 --- a/test/export-surface.json +++ b/test/export-surface.json @@ -66,6 +66,7 @@ "TaggedEvaluateOptions", "decodeTagged", "encodeTagged", - "evaluateTagged" + "evaluateTagged", + "executeTagged" ] } diff --git a/test/tagged.test.ts b/test/tagged.test.ts index da7b9b5..38c364d 100644 --- a/test/tagged.test.ts +++ b/test/tagged.test.ts @@ -1,11 +1,12 @@ 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 { @@ -13,6 +14,7 @@ import { Float, float, fromHost, + isFloat, PDate, PDateTime, toHost, @@ -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 } }); + }); +});