From f854aa4a39a295156d9536398f98d5c9d3eece75 Mon Sep 17 00:00:00 2001 From: JohnnyT Date: Thu, 1 Oct 2026 06:48:43 -0600 Subject: [PATCH 1/2] Adds executeTagged to the tagged subpath execute and executeValue hand a statement program's halt context back under the plain projection, so a program that stores the float two reads back the integer two. executeTagged, on the tagged subpath beside evaluateTagged, runs the same statement program - a compiled list, or source text compiled as compileProgram compiles it - and answers the halt context as the tagged encoding's text on both arms, so decodeTagged reads every value back as the one the program bound. The run is execute's: the main entry point's options, the same budgets and refusals, and the caller's context never written into. A source the program compiler refuses is its ParseError with no context; a context the value boundary refuses carries none either. A halt context the encoding cannot carry is the encoder's reason as an EvaluationError; on the failing arm the run's own error stays the answer and the context it cannot write is left off. It takes no tagged request: the encoding is the only form it answers in, and the tagged option stays evaluateTagged's alone. Tests pin the float on both arms, the compiled-list form, dates, durations and absences, the loop budget, both no-context refusals and both encoding refusals; each new sabotage was run and reverted. The README documents it beside evaluateTagged, and an Added fragment names it. Refs: pts-mmls --- README.md | 33 +++++++++- changelog.d/pts-mmls.md | 3 + src/tagged.ts | 102 ++++++++++++++++++++++++++++-- test/export-surface.json | 3 +- test/tagged.test.ts | 130 ++++++++++++++++++++++++++++++++++++++- 5 files changed, 261 insertions(+), 10 deletions(-) create mode 100644 changelog.d/pts-mmls.md diff --git a/README.md b/README.md index 1dc3334..9e597ae 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,34 @@ 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. + +```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/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 } }); + }); +}); From a7c097534f6a2a3d93538b17b161fc456af24b30 Mon Sep 17 00:00:00 2001 From: JohnnyT Date: Thu, 1 Oct 2026 06:57:55 -0600 Subject: [PATCH 2/2] Notes executeTagged as the subpath's second entry Adds a dated foot Note to ADR-0002, add-only, that decides nothing. The note defining an entry point as a function that runs a program and answers its result listed four such functions; executeTagged is a fifth and the tagged subpath's second. The Note says the tagged option is still accepted by evaluateTagged alone, since executeTagged takes EvaluateOptions and always answers the encoding, and that this change discharges the statement-mode amendment's sentence that a change giving the subpath a statement mode owes the encoding across the returned context. Also states in the README that on the failing arm the run's own error stays and an unencodable partial context is left off, and points execute's doc comment at executeTagged for a host that needs the brand. Refs: pts-mmls --- README.md | 4 +- ...-the-value-domain-and-the-host-boundary.md | 42 +++++++++++++++++++ src/index.ts | 4 +- 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9e597ae..2f4a5c5 100644 --- a/README.md +++ b/README.md @@ -784,7 +784,9 @@ 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. +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"; 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