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
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-28
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
## Context

The v2-calculator's `LOAD_VAR` opcode resolves names **lazily** against the runtime `Environment` (`src/v2-calculator/vm/run.ts:23-28`). Identifiers are scanned by the `ident` grammar rule (`src/v2-calculator/parser/grammar.ohm:31`) and emitted by the compiler as `LOAD_VAR { index: nameIndex(name) }` with no awareness of which names are "built-in". That late-bound architecture is the controlling fact for every decision below: it gives us a free seam to inject defaults into the runtime layer without touching any other layer.

The `Environment` class itself (`src/v2-calculator/vm/Environment.ts`) is a single-scope `Map<string, number>` with `get` (throws `UndefinedVariable` on miss) and `set` (create-or-overwrite). It was deliberately minimal in the variables change — the smallest learnable subset that demonstrates the idea once. Built-in constants extend that minimum the same way the user's own assignments do: through the existing `set` path, just at construction time instead of at user-code time.

## Goals / Non-Goals

**Goals:**

- `pi`, `e`, and `tau` resolve to the mathematically correct double-precision values without any user setup, in every fresh `Environment`.
- Built-ins are reassignable — `pi = 5` silently shadows, matching the calculator's existing "no `let`, implicit reassignment" semantics for user variables.
- Implementation touches **only** the runtime layer (`vm/`). Grammar, parser, semantics, AST, compiler, bytecode, public entry point, and REPL are all unchanged.
- The built-in registry is a **single discoverable module** — `vm/builtins.ts` is the answer to "what names does the language pre-bind?" today, and is positioned to absorb future additions.
- The design does not foreclose any reasonable mechanism for built-in **functions** (the next todo item) — it leaves that decision to the change that adds them.

**Non-Goals:**

- Built-in functions (`sin`, `cos`, `tan`, …) — separate todo, separate change. The `Environment`'s value type (`number`) is not widened here.
- IEEE-754 named constants (`inf`, `nan`, `epsilon`) — separate todo, captured during brainstorming.
- A dedicated opcode for built-in access (Ball's `OpGetBuiltin` pattern from _Writing A Compiler In Go_).
- Compile-time detection that an identifier resolves to a built-in.
- Protecting built-in slots from reassignment.
- A constructor flag for "vanilla" (empty) `Environment` for tests — YAGNI; tests can `set` over the defaults if they truly need to.

## Decisions

### Decision 1: Built-ins are pre-populated bindings in `Environment`, not a separate opcode

The `Environment` constructor pre-fills its existing `Map<string, number>` from a frozen `BUILTIN_CONSTANTS` table. The runtime path is the existing `LOAD_VAR → env.get(name)`. Built-ins are indistinguishable from user variables once they're in the Map.

```ts
// src/v2-calculator/vm/builtins.ts
export const BUILTIN_CONSTANTS: Readonly<Record<string, number>> = Object.freeze({
pi: Math.PI,
e: Math.E,
tau: 2 * Math.PI,
});

// src/v2-calculator/vm/Environment.ts
constructor() {
for (const [name, value] of Object.entries(BUILTIN_CONSTANTS)) {
this.records.set(name, value);
}
}
```

Consequences:

- `pi` already matches the `ident` grammar rule, so the parser and compiler need no changes.
- A fresh `new Environment()` returns `Math.PI` from `get("pi")` immediately.
- `env.set("pi", 5)` overwrites the built-in. Reassignment semantics fall out for free — this is the existing `set` behavior, no special case.
- The compiler still records `["pi"]` in `bytecode.names` and emits `LOAD_VAR 0` for a read of `pi`, exactly as it would for a user variable. Disassembled bytecode remains readable.

**Alternatives considered:**

- _Ball's `OpGetBuiltin` opcode pattern_ (_Writing A Compiler In Go_, ch. on Built-in Functions) — **rejected**. Ball uses a dedicated opcode because his built-ins are _functions_ whose dispatch shape differs from variable reads (they need arity and native dispatch). For numeric constants, the dispatch is _identical_ to user-variable reads, so a separate opcode would be unmotivated ceremony. When the next change introduces built-in functions, `OpGetBuiltin` can be revisited on its merits then — it does not need to be foreshadowed here, and prematurely adopting it would couple constants to a function-dispatch story they don't need.
- _Compile-time inlining: emit `LOAD_CONST Math.PI` whenever the compiler sees `pi`_ — **rejected**. (a) Loses symbolic visibility in disassembly (no `LOAD_VAR` shows `pi`). (b) Requires special-casing in the compiler that would have to be undone when built-in _functions_ arrive (functions cannot be inlined as `LOAD_CONST`). (c) Interacts awkwardly with the chosen "reassignable" semantics — what does the compiler emit for `pi` after a `pi = 5` assignment in the same program? Either the compiler tracks "shadowed" state across the program (complex), or it always emits `LOAD_VAR pi` after seeing any `Assign` to `pi` (special-case), or it always emits `LOAD_VAR pi` and the inlining is only "fast-path" (now we have two paths to maintain). The conceptual model becomes harder than the implementation it tried to optimize.
- _Falling back inside `Environment.get` to a separate built-ins map when the user-map misses_ — **rejected**. Saves the constructor loop but at the cost of two-stage lookup logic on every read, and pushes "knowledge of built-ins" into `get`'s control flow rather than into construction-time data. The constructor loop is cheaper and conceptually simpler ("the env is one Map; built-ins are there from the start").

### Decision 2: `BUILTIN_CONSTANTS` lives in its own module, frozen

The constants table is exported from `src/v2-calculator/vm/builtins.ts` rather than declared as a private static field inside `Environment`. The object is `Object.freeze`d at definition time.

Why a separate module:

- **Discoverability.** A future contributor (or future-you) asking "what does the calculator pre-bind?" greps `BUILTIN_CONSTANTS` and finds _the_ answer in one file. Adding `phi` later is a one-line change in one place.
- **Independence of mechanism from policy.** `Environment` is _mechanism_ (slot storage with get/set semantics). `builtins.ts` is _policy_ (which names exist and what their values are). The two will drift independently over the calculator's lifetime; keeping them in separate files lets each evolve without colliding.
- **Test imports.** `Environment.test.ts` can import `BUILTIN_CONSTANTS` and iterate it to confirm every entry resolves, rather than enumerating names twice (once in the constants file, once in the test).
- **Forward shape.** When `sin`, `cos`, … arrive in a later change, `builtins.ts` is the natural home for a second export (whether that's `BUILTIN_FUNCTIONS`, a widened-value union, or whatever the function change decides). The discovery point is already in place.

Why `Object.freeze`:

- `BUILTIN_CONSTANTS` is intended as read-only data; freezing turns a comment into a hard invariant.
- Cheap insurance against accidental mutation in tests or in some future "let's just hot-patch the constants for this experiment" code path. (Mutating the table would corrupt every subsequent `Environment` constructed in that process.)

**Alternatives considered:**

- _Declare the constants inline in `Environment.ts`_ — **rejected**. Couples the storage class to the policy of which names are pre-bound. Makes the next iteration push more of "what's pre-bound" into `Environment.ts`, which would grow into a god-file.
- _Pass the constants map into `Environment`'s constructor as a parameter_ — **rejected**. Every caller (REPL, `index.ts`, tests) would then need to know about the constants registry, leaking policy across the codebase. The point of pre-population is precisely that callers _don't_ need to think about it. If a caller ever does need a vanilla env, that's a one-line override worth designing then.

### Decision 3: Reassignment overwrites silently — no protected slots, no warnings

Per the brainstorming decision, `pi = 5` is legal and silently overwrites the built-in for the lifetime of that `Environment`. No new error, no warning channel, no compile-time check.

This matches the calculator's existing semantics for _all_ names: no `let` keyword, no `const` keyword, no notion of "protected" identifiers anywhere else. Treating built-ins differently would be the surprise; treating them as ordinary default bindings is the consistency.

Reassignment in one `Environment` does NOT affect any other `Environment` — each instance pre-populates independently in its constructor, so `pi` is restored to `Math.PI` in every freshly-constructed env. This is a natural consequence of pre-populating in the constructor rather than (for example) sharing a single mutable defaults object across instances.

**Alternatives considered:**

- _Reject `pi = 5` at compile time_ — **rejected**. Requires the compiler to consult `BUILTIN_CONSTANTS`, which couples a layer that currently doesn't know about it. Also forces a hard binary choice that's inconsistent with how every other name behaves.
- _Reject `pi = 5` at runtime in `Environment.set`_ — **rejected**. Same problem in a different layer, and worse: now the calculator has _two_ runtime errors for assignment (one for built-ins, the existing-but-absent "you cannot assign that"). The cost-to-benefit of protecting three names is wrong.
- _Warn on shadowing_ — **rejected**. The REPL has no warning channel; `console.warn` mid-evaluation would interleave with output and confuse the prompt. Worth revisiting if/when a richer diagnostics infrastructure arrives.

## Risks / Trade-offs

- **Every `new Environment()` runs a small constructor-time loop over the constants table.** → Loop is O(n) where n is fixed at 3 (and almost certainly <20 even after future additions). Pre-population is one-shot per `Environment` instance; the REPL constructs exactly one for the entire session. Cost is negligible and never on a hot path.
- **The existing scenario titled "A new Environment starts empty" becomes slightly misleading.** → The scenario's body tests resolution of `"x"`, which is _not_ a built-in, so the behavior the scenario asserts (`UndefinedVariable` on unbound name) is unchanged. The scenario remains valid. The spec delta leaves it in place rather than renaming, because renaming would require a MODIFIED block whose only delta is a title change — disproportionate.
- **A future test that imports `BUILTIN_CONSTANTS` and iterates it could become brittle if `Object.freeze` is removed.** → We document the frozen invariant in the spec; if a future change removes the freeze, the spec change captures the new contract.
- **Built-in functions (next todo) cannot be expressed by widening `BUILTIN_CONSTANTS` alone.** → Acknowledged and explicitly out of scope. Decision 1 deliberately preserves that optionality — the next change will add either a sibling registry (`BUILTIN_FUNCTIONS`) or a different opcode (Ball's `OpGetBuiltin`), and either is consistent with this design.

## Open Questions

None at this scope. The follow-ups — IEEE-754 names (`inf`, `nan`) and built-in functions (`sin`, `cos`, …) — are each scoped to their own todo items and own changes.
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
## Why

The v2-calculator's `pi` is currently an undefined variable. A user typing `pi`, `e`, or `tau` at the REPL gets `UndefinedVariable: pi` — a hostile experience for a _math_ calculator. Pre-binding a small set of well-known mathematical constants turns the calculator into something you can use without first defining your own constants every session.

Now that variables exist, every name lookup already goes through `Environment` (`LOAD_VAR` resolves names lazily at runtime — `src/v2-calculator/vm/run.ts:23-28`). That late-bound lookup gives us a free seam to inject built-ins: pre-populating the `Environment` requires no opcode, no grammar, no compiler, and no AST changes. The smallest possible change is also the right change.

## What Changes

- **New module** `src/v2-calculator/vm/builtins.ts` exports a frozen `BUILTIN_CONSTANTS` map of `{ pi: Math.PI, e: Math.E, tau: 2 * Math.PI }`. Single source of truth for "what names does the language pre-bind?", positioned to absorb future entries (`phi`, `inf`, `nan`, …) and eventually built-in functions.
- **`Environment` constructor** pre-populates from `BUILTIN_CONSTANTS`. A fresh `new Environment()` already contains `pi`, `e`, `tau` — no caller change needed in `index.ts`, `cli.ts`, or `run.ts`.
- **Reassignment semantics** — built-ins are ordinary default bindings, not protected slots. `pi = 5` overwrites the binding silently, exactly as user-defined variables do. No new error, no warning.
- **Zero changes** to `grammar.ohm`, `ast.ts`, `semantics.ts`, `parser.ts`, `compiler.ts`, `bytecode.ts`, `run.ts`, `index.ts`, or `cli.ts`. The existing `LOAD_VAR` / `STORE_VAR` paths handle these reads and writes correctly.
- **Tests** in `vm/Environment.test.ts` (fresh-env lookups, shadowing, no regression on undefined-name throw) and `calculator.test.ts` (end-to-end `run("pi")`, `run("2 * pi")`, `run("tau / 2")`, shadowing through the full pipeline).
- **`todo.md`** — tick item 4 (`Add built-in constants, like pi`) and add a follow-up `Add IEEE-754 named constants: inf, nan` adjacent to it.

## Capabilities

### New Capabilities

<!-- None — this extends the existing v2-calculator capability rather than adding a new one. -->

### Modified Capabilities

- `v2-calculator`: `Environment` gains pre-populated bindings for the built-in mathematical constants `pi`, `e`, `tau`. A new requirement covers the existence, identity, and shadowing semantics of these built-ins.

## Out of Scope (Deferred)

- **`inf`, `nan`, `epsilon`.** Captured as a separate todo item. Surfacing IEEE-754 sentinels as user-visible names deserves its own small design discussion (naming, whether `nan == nan`, error-vs-value role) which would clutter this change.
- **Built-in functions (`sin`, `cos`, `tan`, …).** Already a separate todo item. The `Environment` value type is currently `number`; functions will need either a widened value type or Ball's `OpGetBuiltin` pattern (see _Writing A Compiler In Go_, ch. on Built-in Functions). This change deliberately does not prejudge that decision.
- **Compile-time shadowing detection or warnings.** The chosen semantics are "reassignable, no warning", matching the calculator's existing implicit-reassignment stance for user variables.
- **Marking built-ins as `const` / preventing reassignment at runtime.** Same rationale — and the calculator has no `const`-vs-mutable concept anywhere else.
- **Separate `Environment` constructor variant for a "vanilla" empty env.** Tests that need an empty environment can use `set` to overwrite, or the design can grow a flag later. YAGNI for now.

## Impact

- `src/v2-calculator/vm/builtins.ts` (NEW): frozen `Readonly<Record<string, number>>` named `BUILTIN_CONSTANTS` with the three initial entries.
- `src/v2-calculator/vm/Environment.ts`: constructor pre-populates the internal `Map` from `BUILTIN_CONSTANTS` via `Object.entries`. No method signatures change.
- `src/v2-calculator/vm/Environment.test.ts`: new cases for fresh-env built-in resolution, user shadowing, and a confirmation that `UndefinedVariable` still throws for non-built-in names.
- `src/v2-calculator/calculator.test.ts`: new end-to-end cases covering `pi`, `e`, `tau` evaluation through the full parse → compile → run pipeline, plus REPL-like shadowing across two `run` calls sharing one `Environment`.
- `src/v2-calculator/todo.md`: item 4 ticked; new follow-up item added beneath it.
- `src/v2-calculator/parser/*`, `src/v2-calculator/compiler/*`, `src/v2-calculator/vm/run.ts`, `src/v2-calculator/index.ts`, `src/v2-calculator/cli.ts`: **unchanged**. This is the design's main virtue — the change rides entirely on existing infrastructure.
- After approval + merge: sync delta into `openspec/specs/v2-calculator/spec.md` via the `openspec-sync-specs` skill.
Loading