diff --git a/AGENTS.md b/AGENTS.md index e239def9b..68cd74d68 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -306,12 +306,14 @@ import type ... from '../other/types.ts' import type ... from './module.f.mjs' ``` -The one exception is `fjs/emergent_testing/scenarios/*.ts`, `scenarios/all.ts` -and `all.test.ts`, which do have runtime imports. Their `.ts` extension is -load-bearing — `run.sh` dispatches on it to prove that Node, Bun and Deno execute -a **TypeScript** proof natively — so they are deliberately not `types.ts` and -must not be ported to `.mjs`. See the scenario-fixture item in -[`todo/migrate-typescript-to-mjs.md`](./todo/migrate-typescript-to-mjs.md). +The one exception is `fjs/emergent_testing/all.test.ts`, the entry point that +external runners load, which does have runtime imports and is therefore +deliberately not `types.ts`. It is the only authored non-`types.ts` TypeScript +left in the repository; `prepack` compiles it to the published +`all.test.js` that consumers import. The scenario fixtures that used to share +this exception are gone — see +[`fjs/emergent_testing/scenarios.md`](./fjs/emergent_testing/scenarios.md) for +what they covered and how to rebuild them. The runtime-import grouping applies to repository-owned relative imports, not to external or built-in modules: a FunctionalScript module may depend at runtime on diff --git a/CHANGELOG.md b/CHANGELOG.md index 1eab74865..2de919424 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,10 @@ history. ## Unreleased +- `fjs/types/bigfloat`: `decToBin` no longer returns a 54-bit mantissa when + rounding carries out of 53 bits; the result is always a binary64 significand + (`abs(m) < 2^53`) + [#1524](https://github.com/functionalscript/functionalscript/pull/1524) - `fjs/text/ascii` owns the hex-digit codec: `hexDigitValue`, `hexDigitCodePoint`, and the `a-f` / `A-F` ranges. The JSON serializer and both tokenizers use it instead of rederiving the offsets; the DJS tokenizer diff --git a/fjs/emergent_testing/README.md b/fjs/emergent_testing/README.md index 83639480e..db9c2ec7f 100644 --- a/fjs/emergent_testing/README.md +++ b/fjs/emergent_testing/README.md @@ -86,6 +86,10 @@ Then invoke the runner: You can also implement your own runner, as long as it follows the proof-tree conventions described below. +This repository used to check the external runners against fixtures with known +pass/fail outcomes. That harness has been removed; +[scenarios.md](./scenarios.md) records what it covered and how to rebuild it. + ## Design: dependency-free proofs Unlike most test frameworks (Jest, Mocha, Vitest, …), a proof does **not** import diff --git a/fjs/emergent_testing/scenarios.md b/fjs/emergent_testing/scenarios.md new file mode 100644 index 000000000..e154fcd24 --- /dev/null +++ b/fjs/emergent_testing/scenarios.md @@ -0,0 +1,259 @@ +# Scenario fixtures + +External-runner scenario fixtures used to live in +`fjs/emergent_testing/scenarios/`. They have been **removed**. This file records +what they were and how to rebuild them, so the capability can be recreated +deliberately rather than reconstructed from git archaeology. + +## What they were + +Nine one-module fixtures plus a shell harness. Each fixture exported a `proof` +whose outcome under an external runner (`node --test`, `bun test`, +`deno test`) was known in advance from its filename: `*.pass.ts` had to exit +`0`, `*.fail.ts` had to exit `1`. The harness ran one fixture at a time and +compared the runner's exit status against that expectation. + +They tested the framework end to end — registration, async handling, sub-tests, +throw tests, thenable handling — through a real runner in a real process, which +is the one thing the in-process proofs in +[`proof.f.mjs`](./proof.f.mjs) cannot do for themselves. + +## Why they were removed + +- **Nothing ran them.** No CI job and no generated workflow invoked + `scenarios/run.sh`; it was a manual, undocumented step. +- **Part of it had already rotted.** `run.sh`'s `fjs` runner branch ran + `npm run fst`, a script that no longer exists in `package.json`, so that + quarter of the matrix reported `FAIL` for every fixture regardless of the + fixture. Nobody noticed, which is the clearest evidence they were unrun. +- **They blocked a migration.** `scenarios/*.ts` and `scenarios/all.ts` were, + with [`all.test.ts`](./all.test.ts), the last authored non-`types.ts` + TypeScript in the repository — see + [`todo/migrate-typescript-to-mjs.md`](../../todo/migrate-typescript-to-mjs.md). + +Removing them deletes real coverage. That is the trade being accepted: the +coverage was not being collected anyway. Recreate it — from this file — if it +is wanted back, ideally wired into CI in the same change. + +## What they covered + +| Fixture | Expected exit | What it proved | +| --- | --- | --- | +| `fail.fail.ts` | `1` | A throwing test case fails the run. | +| `async.pass.ts` | `0` | An `async` test case is awaited and passes. | +| `async.fail.ts` | `1` | An `async` test case that rejects fails the run. | +| `async-subtests.pass.ts` | `0` | An `async` case returning an object of test cases has them run as sub-tests. | +| `async-subtests.fail.ts` | `1` | One failing sub-test of an `async` case fails the run. | +| `return-value.pass.ts` | `0` | A case returning an object is walked as a sub-tree. | +| `throw.pass.ts` | `0` | A case under a `throw` key passes *because* it throws. | +| `thenable.pass.ts` | `0` | A thenable is treated as a plain value, not awaited: its only key `then` is a function *with parameters*, so no leaf test is found and the run trivially passes. | +| `thenable2.pass.ts` | `0` | Same, for a zero-parameter `then` returning a value. | + +The two thenable cases are the subtle ones and the reason to keep the set if it +is ever rebuilt: they pin FunctionalScript's decision that thenables are *not* +awaited, a rule no other test states. + +## How to recreate + +### 1. The fixtures + +Each is a standalone module exporting `proof`, with no imports. Recreate them +verbatim: + +```ts +// fail.fail.ts +export const proof = { + failing: () => { throw 'intentional failure' } +} +``` + +```ts +// async.pass.ts +export const proof = { + sleep: async () => { + await new Promise(resolve => setTimeout(resolve, 10)) + } +} +``` + +```ts +// async.fail.ts +export const proof = { + sleep_fail: async () => { + await new Promise(resolve => setTimeout(resolve, 10)) + throw 'async failure' + } +} +``` + +```ts +// async-subtests.pass.ts +export const proof = { + withSubtests: async () => { + await new Promise(resolve => setTimeout(resolve, 10)) + return { + sub1: () => {}, + sub2: () => {}, + } + } +} +``` + +```ts +// async-subtests.fail.ts +export const proof = { + withSubtests: async () => { + await new Promise(resolve => setTimeout(resolve, 10)) + return { + sub1: () => {}, + sub2: () => { throw 'sub-test failure' }, + } + } +} +``` + +```ts +// return-value.pass.ts +const inner = () => {} + +export const proof = { + outer: (): unknown => ({ inner }) +} +``` + +```ts +// throw.pass.ts +export const proof = { + throw: { a: () => { throw 'expected' } } +} +``` + +```ts +// thenable.pass.ts +export const proof = { + thenableResolves: () => ({ + then(resolve: (v: undefined) => void) { resolve(undefined) } + }) +} +``` + +```ts +// thenable2.pass.ts +export const proof = { + shouldPass: () => ({ then: () => 'ok' }) +} +``` + +### 2. The entry-point shim + +```ts +// all.ts +import '../all.test.ts' +``` + +This one-line file is **not** redundant with `all.test.ts`, and the reason is +the single most easily lost piece of this design — see +[Two traps](#two-traps) below. + +### 3. The harness + +```sh +#!/bin/sh +# Usage: run.sh +# runner: fjs | bun | node | deno +# scenario: path to a *.pass.ts or *.fail.ts file +set -e + +runner=$1 +scenario=$(realpath "$2") + +scendir=$(cd "$(dirname "$0")" && pwd) + +case "$scenario" in + *.pass.ts) expected=0; scenfile="$scendir/_scenario.proof.ts" ;; + *.fail.ts) expected=1; scenfile="$scendir/_scenario.proof.ts" ;; + *) echo "unknown suffix: $scenario" >&2; exit 2 ;; +esac +allfile="$scendir/_all.test.ts" + +ln "$scenario" "$scenfile" +ln "$scendir/all.ts" "$allfile" + +cleanup() { rm -f "$scenfile" "$allfile"; } +trap cleanup EXIT + +case "$runner" in + fjs) cmd="npm run fst" ;; + bun) cmd="bun test" ;; + node) cmd="node --test" ;; + deno) cmd="deno test --allow-read --allow-env --allow-sys" ;; + *) echo "unknown runner: $runner" >&2; exit 2 ;; +esac + +actual=0 +(cd "$scendir" && $cmd) > /dev/null 2>&1 || actual=$? + +if [ "$actual" -eq "$expected" ]; then + echo "pass: $(basename "$scenario") [exit $actual]" + exit 0 +else + echo "FAIL: $(basename "$scenario") [expected $expected, got $actual]" + exit 1 +fi +``` + +Invoked as `sh run.sh node ./fail.fail.ts`. Fix the `fjs` branch before +reusing it: `npm run fst` does not exist. Whatever replaces it must run the +built-in runner over the scenario directory and exit non-zero on failure. + +The harness hard-links exactly two files into the scenario directory, +`_scenario.proof.ts` (the fixture, renamed so the built-in runner's +`proof`-module discovery finds it) and `_all.test.ts` (the shim, renamed so the +external runner's `*.test.*` discovery finds it), runs the runner with the +directory as its working directory, and removes both links on exit. + +## Two traps + +Anyone rebuilding this will hit both. + +### The shim cannot be replaced by hard-linking `all.test.ts` + +A hard link has no "original": both names are equal directory entries to one +inode, and Node resolves a module's relative specifiers from whichever path it +was reached through. `all.test.ts` imports `../effects/node/module.mjs`, which +is correct at `fjs/emergent_testing/` and wrong one level deeper. Hard-linking +it into `scenarios/` fails at load: + +``` +Error [ERR_MODULE_NOT_FOUND]: Cannot find module + '.../fjs/emergent_testing/effects/node/module.mjs' + imported from .../scenarios/_all.test.ts +``` + +The shim works because it *lives in the directory it is linked into*, so its +own `../all.test.ts` stays correct at both paths. A symlink would resolve to +its realpath and avoid the shim entirely — a legitimate simplification if the +harness is rewritten, and one worth taking, since it collapses two files into +one. + +### The shim must not be named `*.test.*` at rest + +External runners scan the directory. If the at-rest shim also matched +`*.test.*`, the runner would discover both it and the `_all.test.ts` hard link +and register the whole suite twice — Node caches modules by resolved URL, not +by inode. That is why the file is `all.ts` and the link is `_all.test.ts`. +See [`todo/65z-singleton-effect.md`](./todo/65z-singleton-effect.md), which +proposes a general fix for duplicate proof execution under multiple paths. + +## If you rebuild it + +- **Wire it into CI in the same change.** An unrun harness rots silently, which + is how this one ended up with a permanently failing runner branch. +- **Decide the language deliberately.** These fixtures were TypeScript on + purpose: they proved Node, Bun and Deno execute a *TypeScript* proof + natively. If that property no longer needs testing, `.mjs` fixtures are + simpler and drop the fixtures from the `prepack` emit pass. +- **Prefer a symlink or a generated file** over the hard link, and the shim + disappears. +- **Keep the thenable cases.** They are the only statement of the + not-awaited rule. diff --git a/fjs/emergent_testing/scenarios/all.ts b/fjs/emergent_testing/scenarios/all.ts deleted file mode 100644 index ed007dd75..000000000 --- a/fjs/emergent_testing/scenarios/all.ts +++ /dev/null @@ -1 +0,0 @@ -import '../all.test.ts' diff --git a/fjs/emergent_testing/scenarios/async-subtests.fail.ts b/fjs/emergent_testing/scenarios/async-subtests.fail.ts deleted file mode 100644 index da5f3df5a..000000000 --- a/fjs/emergent_testing/scenarios/async-subtests.fail.ts +++ /dev/null @@ -1,9 +0,0 @@ -export const proof = { - withSubtests: async () => { - await new Promise(resolve => setTimeout(resolve, 10)) - return { - sub1: () => {}, - sub2: () => { throw 'sub-test failure' }, - } - } -} diff --git a/fjs/emergent_testing/scenarios/async-subtests.pass.ts b/fjs/emergent_testing/scenarios/async-subtests.pass.ts deleted file mode 100644 index e83047f55..000000000 --- a/fjs/emergent_testing/scenarios/async-subtests.pass.ts +++ /dev/null @@ -1,9 +0,0 @@ -export const proof = { - withSubtests: async () => { - await new Promise(resolve => setTimeout(resolve, 10)) - return { - sub1: () => {}, - sub2: () => {}, - } - } -} diff --git a/fjs/emergent_testing/scenarios/async.fail.ts b/fjs/emergent_testing/scenarios/async.fail.ts deleted file mode 100644 index af8170581..000000000 --- a/fjs/emergent_testing/scenarios/async.fail.ts +++ /dev/null @@ -1,6 +0,0 @@ -export const proof = { - sleep_fail: async () => { - await new Promise(resolve => setTimeout(resolve, 10)) - throw 'async failure' - } -} diff --git a/fjs/emergent_testing/scenarios/async.pass.ts b/fjs/emergent_testing/scenarios/async.pass.ts deleted file mode 100644 index ba1dedefa..000000000 --- a/fjs/emergent_testing/scenarios/async.pass.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const proof = { - sleep: async () => { - await new Promise(resolve => setTimeout(resolve, 10)) - } -} diff --git a/fjs/emergent_testing/scenarios/fail.fail.ts b/fjs/emergent_testing/scenarios/fail.fail.ts deleted file mode 100644 index 5c1c4519d..000000000 --- a/fjs/emergent_testing/scenarios/fail.fail.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const proof = { - failing: () => { throw 'intentional failure' } -} diff --git a/fjs/emergent_testing/scenarios/return-value.pass.ts b/fjs/emergent_testing/scenarios/return-value.pass.ts deleted file mode 100644 index 75a66b023..000000000 --- a/fjs/emergent_testing/scenarios/return-value.pass.ts +++ /dev/null @@ -1,5 +0,0 @@ -const inner = () => {} - -export const proof = { - outer: (): unknown => ({ inner }) -} diff --git a/fjs/emergent_testing/scenarios/run.sh b/fjs/emergent_testing/scenarios/run.sh deleted file mode 100755 index 7ac6119d6..000000000 --- a/fjs/emergent_testing/scenarios/run.sh +++ /dev/null @@ -1,42 +0,0 @@ -#!/bin/sh -# Usage: run.sh -# runner: fjs | bun | node | deno -# scenario: path to a *.pass.f.ts, *.fail.f.ts, *.pass.ts or *.fail.ts file -set -e - -runner=$1 -scenario=$(realpath "$2") - -scendir=$(cd "$(dirname "$0")" && pwd) - -case "$scenario" in - *.pass.ts) expected=0; scenfile="$scendir/_scenario.proof.ts" ;; - *.fail.ts) expected=1; scenfile="$scendir/_scenario.proof.ts" ;; - *) echo "unknown suffix: $scenario" >&2; exit 2 ;; -esac -allfile="$scendir/_all.test.ts" - -ln "$scenario" "$scenfile" -ln "$scendir/all.ts" "$allfile" - -cleanup() { rm -f "$scenfile" "$allfile"; } -trap cleanup EXIT - -case "$runner" in - fjs) cmd="npm run fst" ;; - bun) cmd="bun test" ;; - node) cmd="node --test" ;; - deno) cmd="deno test --allow-read --allow-env --allow-sys" ;; - *) echo "unknown runner: $runner" >&2; exit 2 ;; -esac - -actual=0 -(cd "$scendir" && $cmd) > /dev/null 2>&1 || actual=$? - -if [ "$actual" -eq "$expected" ]; then - echo "pass: $(basename "$scenario") [exit $actual]" - exit 0 -else - echo "FAIL: $(basename "$scenario") [expected $expected, got $actual]" - exit 1 -fi diff --git a/fjs/emergent_testing/scenarios/thenable.pass.ts b/fjs/emergent_testing/scenarios/thenable.pass.ts deleted file mode 100644 index 0b19cc6c8..000000000 --- a/fjs/emergent_testing/scenarios/thenable.pass.ts +++ /dev/null @@ -1,11 +0,0 @@ -// A test that returns a thenable (Promise-like object, not a real Promise). -// Per FunctionalScript convention, thenables are treated as plain values — -// not awaited. Both sandbox (fjs) and registerModule (node/bun/deno) -// must exit 0: the thenable object is walked as a sub-tree whose only key -// `then` is a function with parameters, so no leaf tests are found and the -// test trivially passes. -export const proof = { - thenableResolves: () => ({ - then(resolve: (v: undefined) => void) { resolve(undefined) } - }) -} diff --git a/fjs/emergent_testing/scenarios/thenable2.pass.ts b/fjs/emergent_testing/scenarios/thenable2.pass.ts deleted file mode 100644 index 18ea7a15f..000000000 --- a/fjs/emergent_testing/scenarios/thenable2.pass.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const proof = { - shouldPass: () => ({ then: () => 'ok' }) -} diff --git a/fjs/emergent_testing/scenarios/throw.pass.ts b/fjs/emergent_testing/scenarios/throw.pass.ts deleted file mode 100644 index 47ad01f38..000000000 --- a/fjs/emergent_testing/scenarios/throw.pass.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const proof = { - throw: { a: () => { throw 'expected' } } -} diff --git a/fjs/emergent_testing/todo/205.md b/fjs/emergent_testing/todo/205.md deleted file mode 100644 index b594b143e..000000000 --- a/fjs/emergent_testing/todo/205.md +++ /dev/null @@ -1,38 +0,0 @@ -## 205. Rename `all.test.ts` entry point - -**Priority:** P3 -**Status:** open - -### Problem - -The `fjs/emergetn-testing/scenarios/all.ts` file (renamed to `all.test.ts` by `run.sh` at test -time) is named `all`, which suggests "run all tests" rather than "register tests with -an external framework". - -The `.test.ts` suffix **must be kept** — bun, node `--test`, and deno -auto-discover files ending in `.test.ts`. A name like `register.ts` (without the -`.test.ts` suffix) would not be found by any framework. - -### Options - -#### Option A — `register.test.ts` - -Rename `all.ts` → `register.ts` (at rest); `run.sh` links it as `register.test.ts`. -The `.test.ts` suffix preserves auto-discovery; the `register` prefix communicates -the file's role. - -Note: `loadModuleMap` only matches `*.test.f.ts` / `*.test.f.js`, so `register.test.ts` -would not be loaded as a test module — no double-load risk, even if plain -`*.test.ts` support is added (since the guard would need to explicitly exclude -`register.test.ts` or use a different mechanism). - -#### Option B — keep `all.ts` / `all.test.ts` - -Accept the current name. `all` is short and familiar; the entry-point role is clear -from context. - -### Related - -- i204 — new suffix for plain TS/JS FunctionalScript convention files; - `all.test.ts` must stay `.test.ts` for framework discovery -- i183 — scenario runner that uses this file diff --git a/fjs/emergent_testing/todo/65z-singleton-effect.md b/fjs/emergent_testing/todo/65z-singleton-effect.md index 9f426cf5f..60b4f7e5c 100644 --- a/fjs/emergent_testing/todo/65z-singleton-effect.md +++ b/fjs/emergent_testing/todo/65z-singleton-effect.md @@ -1,7 +1,7 @@ ## 65Z-singleton-effect. Singleton effect to prevent duplicate proof execution **Priority:** P3 -**Status:** open +**Status:** on-hold ### Problem @@ -10,18 +10,23 @@ copies, or a test runner discovering both the original and a generated alias — its `proof` export is executed multiple times in the same process. This wastes time and can produce confusing duplicate output. -#### Concrete example: scenario runner +**On hold: nothing in the tree loads a module under two paths today.** The +motivating case was the scenario runner, since deleted — see +[`../scenarios.md`](../scenarios.md). This becomes live again the moment that +harness is rebuilt, or any other multi-path loading appears. -`run.sh` hard-links `all.ts` → `_all.test.ts` and a scenario file → -`_scenario.proof.ts`, then runs a test framework (node, bun, deno) +#### Historical example: scenario runner + +`run.sh` hard-linked `all.ts` → `_all.test.ts` and a scenario file → +`_scenario.proof.ts`, then ran a test framework (node, bun, deno) in the `scenarios/` directory. If the framework scans the directory it may -discover **both** `all.ts` and `_all.test.ts` (both end in `.ts` and both -export a `run()` call). Each discovered file loads and executes the module -independently under its own resolved path — Node.js caches by resolved URL, -not by inode — so the proof suite runs twice. +discover **both** the at-rest shim and the `_all.test.ts` link. Each discovered +file loads and executes the module independently under its own resolved path — +Node.js caches by resolved URL, not by inode — so the proof suite runs twice. +That is why the shim was named `all.ts` rather than `all.test.ts`. -The same issue would arise if `all.ts` were copied to multiple locations for -use in different test environments. +The same issue would arise if an entry point were copied to multiple locations +for use in different test environments. ### Proposal: a `singleton` effect @@ -101,12 +106,12 @@ importing them. This requires a `stat()` call per file and is specific to Unix; it does not generalise to copied files or other runtimes (Deno, Bun, browsers). -#### Alternative: avoid the problem in run.sh +#### Alternative: avoid the problem in the harness -Instead of hard-linking `all.ts` → `_all.test.ts`, `run.sh` could use a -wrapper file that only imports `_scenario.proof.ts` and does not re-export -`all.ts`. This avoids the duplication for the scenario case but does not -address the general problem. +A rebuilt scenario harness could sidestep this entirely: a symlink resolves to +its realpath, so no second discoverable file exists, and the shim is not needed +either. That avoids the duplication for the scenario case but does not address +the general problem. ### Related diff --git a/fjs/types/bigfloat/module.f.mjs b/fjs/types/bigfloat/module.f.mjs index dd590dcd8..831abfbe7 100644 --- a/fjs/types/bigfloat/module.f.mjs +++ b/fjs/types/bigfloat/module.f.mjs @@ -61,21 +61,49 @@ const divide = ([m, e]) => div => [[m / div, e], m % div] */ const withSign = (m, e) => f => multiply(f([abs(m), e]))(BigInt(sign(m))) -/** @type {(_: _BigFloatWithRemainder) => BigFloat} */ +/** + * Rounds a magnitude and its division remainder to a 53-bit mantissa, + * half-to-even, restoring the sign of `m` on the result. + * + * Rounding up can carry out of 53 bits: when the reduced mantissa is + * `2^53 - 1`, adding the dropped bit back gives exactly `2^53`, one bit wider + * than this function is named for. `decreaseMantissa` re-normalizes that case + * back into range; the carried value is exactly `2^53`, whose dropped bit is + * `0`, so shifting it needs no second rounding decision and the value is + * unchanged. + * + * @type {(_: _BigFloatWithRemainder) => BigFloat} + */ const round53 = ([[m, e], r]) => withSign(m, e)(([mAbs]) => { const [m54, e54] = decreaseMantissa([mAbs, e])(twoPow54) const o54 = m54 & 1n const m53 = m54 >> 1n const e53 = e54 + 1 - if (o54 === 1n && r === 0n && mAbs === m54 >> BigInt(e - e54)) { - const odd = m53 & 1n - return [m53 + odd, e53] - } - return [m53 + o54, e53] + const up = o54 === 1n && r === 0n && mAbs === m54 >> BigInt(e - e54) + ? m53 & 1n + : o54 + return decreaseMantissa([m53 + up, e53])(twoPow53) }) -/** @type {(dec: BigFloat) => BigFloat} */ +/** + * Converts a decimal `BigFloat` to a binary one, rounding the mantissa + * half-to-even. + * + * The mantissa magnitude is always strictly below `2^53`, including when + * rounding carries (see `round53`), so it fits an IEEE-754 binary64 + * significand. A zero input maps to `[0n, 0]`. + * + * The *exponent* is not clamped to binary64's range: nothing here overflows to + * infinity, underflows to zero, or denormalizes. The pair is therefore the + * encodable significand only for the normal range (biased exponent + * `0x001`..`0x7fe`). In the `0x000` range the mantissa is still normalized to + * 53 bits where the encoded value keeps at most 52, so re-rounding this result + * onto the subnormal grid rounds a second time and can land one ulp off the + * correctly-rounded double. + * + * @type {(dec: BigFloat) => BigFloat} + */ export const decToBin = dec => { if (dec[0] === 0n) { return [0n, 0] diff --git a/fjs/types/bigfloat/proof.f.mjs b/fjs/types/bigfloat/proof.f.mjs index b7ebbd7fc..b7c3b8c81 100644 --- a/fjs/types/bigfloat/proof.f.mjs +++ b/fjs/types/bigfloat/proof.f.mjs @@ -1,5 +1,26 @@ +/** + * @import { BigFloat } from './types.ts' + */ + import { decToBin } from './module.f.mjs' -import { assertEq } from '../../asserts/module.f.mjs' +import { assert, assertEq } from '../../asserts/module.f.mjs' +import { abs } from '../bigint/module.f.mjs' + +const twoPow53 = 0b0010_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000n + +/** + * Asserts that `decToBin(dec)` equals `expected` and that its mantissa is a + * binary64 significand — `abs(m) < 2^53`. The width check is on the + * magnitude: `m < 2^53` is vacuous for a negative mantissa. + * + * @type {(dec: BigFloat) => (expected: BigFloat) => void} + */ +const assertDecToBin = dec => ([em, ee]) => { + const [m, e] = decToBin(dec) + assert(abs(m) < twoPow53, m.toString(2)) + assertEq(m, em, m.toString(2)) + assertEq(e, ee) +} export const proof = { decToBin: [ @@ -214,5 +235,44 @@ export const proof = { assertEq(result[0], -0b1_1001_1001_1001_1001_1001_1001_1001_1001_1001_1001_1001_1001_1100n, result[0].toString(2)) assertEq(result[1], 2) } + ], + // Rounding up out of 53 bits: the mantissa reaches exactly 2^53 before + // re-normalization. The value is unchanged, only the representation moves + // one bit right. + roundingCarry: [ + () => { + // 2^54 - 1 (54 bits, exact): a tie, and 2^53 - 1 is odd, so + // half-to-even rounds up to 2^54 = 2^52 * 2^2. + assertDecToBin([0b11_1111_1111_1111_1111_1111_1111_1111_1111_1111_1111_1111_1111_1111n, 0])( + [0b1_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000n, 2]) + }, + () => { + assertDecToBin([-0b11_1111_1111_1111_1111_1111_1111_1111_1111_1111_1111_1111_1111_1111n, 0])( + [-0b1_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000n, 2]) + }, + () => { + // 2^55 - 1 (55 bits): the reduction to 54 bits drops a `1`, so this + // is not a tie; the plain round-up carries just the same, to + // 2^55 = 2^52 * 2^3. + assertDecToBin([0b111_1111_1111_1111_1111_1111_1111_1111_1111_1111_1111_1111_1111_1111n, 0])( + [0b1_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000n, 3]) + }, + () => { + assertDecToBin([-0b111_1111_1111_1111_1111_1111_1111_1111_1111_1111_1111_1111_1111_1111n, 0])( + [-0b1_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000n, 3]) + }, + ], + // The mantissa width holds at every scale, not just where binary64 has a + // normal exponent. These two land where binary64 would encode a subnormal + // (biased exponent 0x000, at most 52 significand bits) and an infinity + // (0x7ff); `decToBin` clamps neither, and still returns 53 bits. Hex, not + // binary: these mantissas have no bit pattern worth reading. + farExponent: [ + () => { + assertDecToBin([1n, -320])([0x1f_a017_12e8_f047n, -1116]) + }, + () => { + assertDecToBin([1n, 400])([0x1b_4ec7_f919_73ffn, 1276]) + }, ] } diff --git a/fjs/types/bigfloat/todo/binary64-exponent-range.md b/fjs/types/bigfloat/todo/binary64-exponent-range.md new file mode 100644 index 000000000..b6065ec71 --- /dev/null +++ b/fjs/types/bigfloat/todo/binary64-exponent-range.md @@ -0,0 +1,85 @@ +## Map `decToBin` onto the binary64 exponent range + +**Priority:** P3 +**Status:** open + +### Problem + +`decToBin` (`fjs/types/bigfloat/module.f.mjs`) rounds the mantissa to 53 bits +and stops there. It never looks at the exponent, so nothing in it models +binary64's exponent range: no overflow to infinity, no underflow to zero, and +no denormalization. It returns a *normalized* pair at every scale. + +That is exactly right for the normal range (biased exponent `0x001`..`0x7fe`), +where a binary64 significand is 53 bits. It is not the encodable significand +anywhere else: + +| input | `decToBin` | the actual double | +| ---------- | -------------------------------- | ------------------------ | +| `1e-310` | `[0x12_688b_70e6_2b10n, -1082]` | field `0x000`, 45 bits | +| `1e-320` | `[0x1f_a017_12e8_f047n, -1116]` | field `0x000`, 11 bits | +| `5e-324` | `[0x10_3132_b9cf_541cn, -1126]` | field `0x000`, 1 bit | +| `1e-400` | `[0x12_bfcf_c0f9_23dfn, -1381]` | field `0x000`, `0` (underflow) | +| `1e400` | `[0x1b_4ec7_f919_73ffn, 1276]` | field `0x7ff` (infinity) | + +In the whole `0x000` range `decToBin` carries *more* precision than the value +it is converting to can hold, and at the top it hands back a finite pair where +binary64 has already saturated. + +### The trap: a second rounding double-rounds + +The obvious way to finish the job — take `decToBin`'s output and shift it onto +the subnormal grid (multiples of `2^-1074`) — rounds a second time, and the two +roundings do not compose. Constructed counter-example: let `q = 2^44 + 1` (odd, +45 bits) and let the input be the exact decimal for `((2q + 1) << 80) - 1` +over `2^1155` — a value just *below* the tie point between `q` and `q + 1` on +the subnormal grid. + +- The correctly-rounded double is `q` (`0x100000000001`); `Number()` agrees. +- `decToBin` rounds it up to the tie point exactly — `[0x10_0000_0000_0180n, + -1082]` — and grid-rounding that tie half-to-even, with `q` odd, goes *up* to + `0x100000000002`. + +One ulp off, and no amount of care in the second step recovers it: the +information that decides the case was discarded by the first rounding. A +correct implementation has to round once, directly to the target precision. + +### Proposal + +Decide first whether this belongs in `decToBin` at all. Two shapes: + +1. **A separate `toBinary64`** that takes the decimal `BigFloat` and rounds + once to the binary64 grid — normal, subnormal, and saturating cases — while + `decToBin` keeps its current unbounded-exponent contract for callers that + want the exact normalized pair. The precision to round to is a function of + the exponent, so this is one rounding with a computed bit budget, not + `decToBin` followed by a fixup. +2. **A range-limited `decToBin`**, which makes the current unbounded behavior + unreachable. Only worth it if no caller ever wants the exact pair. + +Option 1 looks right: the unbounded pair is the more primitive result, and +`round53` already has the shape a variable bit budget needs — it is +`decreaseMantissa` to a bound, then one rounding decision. Generalizing that +bound from `twoPow54` to a computed one is the core of the work. + +Whichever is chosen, the postcondition documented on `decToBin` must stay +honest about which range it covers. + +### Tasks + +- [ ] Decide between a separate `toBinary64` and a range-limited `decToBin`. +- [ ] Generalize `round53`'s fixed 54-bit budget to a computed one so the + subnormal case rounds exactly once. +- [ ] Handle saturation: overflow to infinity and underflow to zero, including + the half-way cases at both boundaries. +- [ ] Proofs: the double-rounding construction above, every subnormal width + from 1 to 52 bits, both boundaries, and both signs. `Number(string)` is a + correctly-rounded oracle and can be compared against exactly. +- [ ] Update the `decToBin` JSDoc once the range is covered. + +### Related + +- [from-decimal](from-decimal.md) — the other missing half of the same + pipeline: decimal literal → `BigFloat`, before this stage. +- `fjs/types/bigfloat/module.f.mjs` — `decToBin`, `round53`, + `decreaseMantissa`. diff --git a/fjs/types/bigfloat/todo/from-decimal.md b/fjs/types/bigfloat/todo/from-decimal.md index 81284aaca..ae0be41f5 100644 --- a/fjs/types/bigfloat/todo/from-decimal.md +++ b/fjs/types/bigfloat/todo/from-decimal.md @@ -36,5 +36,3 @@ Add `fromDecimalParts(sign, intDigits, fracDigits, expSign, expDigits)` (or a - [tokenizer-finish-number-shared](../../../js/todo/tokenizer-finish-number-shared.md) — number *completeness* classification, a different concern -- [round53-overflow](round53-overflow.md) — `decToBin`, the next stage of the - same pipeline diff --git a/fjs/types/bigfloat/todo/round53-overflow.md b/fjs/types/bigfloat/todo/round53-overflow.md deleted file mode 100644 index da9f37fbe..000000000 --- a/fjs/types/bigfloat/todo/round53-overflow.md +++ /dev/null @@ -1,82 +0,0 @@ -## round53-overflow. `decToBin` returns a 54-bit mantissa when rounding carries - -**Priority:** P3 -**Status:** open - -### Problem - -`round53` (`fjs/types/bigfloat/module.f.mjs`) reduces the mantissa to 54 bits -with `decreaseMantissa(...)(twoPow54)`, then rounds to 53 bits by adding the -dropped bit back: - -```ts -const m53 = m54 >> 1n -const e53 = e54 + 1 -... -return [m53 + o54, e53] -``` - -Neither return path re-normalizes after the addition. When `m54` is all -ones, `m53 = 2^53 - 1` and the round-up carries into a 54th bit, so the -result leaves the function with a mantissa of exactly `2^53` — one bit wider -than the 53 bits the function is named for. - -Reproduced against the current implementation: - -```ts -decToBin([18014398509481983n, 0]) // 2^54 - 1 -// => [9007199254740992n, 1] // mantissa = 2^53, 54 bits -decToBin([-18014398509481983n, 0]) -// => [-9007199254740992n, 1] // same, negative -``` - -The numeric value is correct (`2^53 * 2^1 = 2^54`); only the -representation is out of range. Neighbouring inputs behave: -`decToBin([18014398509481981n, 0])` returns a 53-bit mantissa. The carry -path is reachable from both branches of `decToBin` — the tie-to-even -branch (above) and the plain `m53 + o54` branch. - -This matters because the whole point of `decToBin` is to produce the -IEEE-754 binary64 significand: a consumer that assumes `abs(m) < 2^53` (to -emit the significand field, to compare two `BigFloat`s by mantissa, or to -round-trip through a `number`) is wrong on exactly these inputs. - -Nothing consumes `decToBin` today. `bigfloat` does have importers — the JSON, -DJS, and JS tokenizers (`fjs/media/json/tokenizer`, `fjs/djs/tokenizer`, -`fjs/js/tokenizer`) — but they take only `multiply` and the `BigFloat` type, -not the decimal→binary conversion, so no current caller can observe the -oversized mantissa. That is why this is P3 and not higher; it stops being -true the moment a tokenizer's `BigFloat` is converted for a `number`. - -### Proposal - -Re-normalize after rounding: if the rounded mantissa reaches `twoPow53`, -shift it right one bit and increment the exponent. The value is unchanged -(the dropped bit is always 0 at that point, since the mantissa is exactly -`2^53`), so no second rounding decision is needed. - -Both `return` sites in `round53` need it — factor the fix into a single -helper applied to the result rather than duplicating the check. - -The alternative — deciding that a 54-bit mantissa is an acceptable output -and documenting the postcondition as "value-correct, not normalized" — is -worse: it pushes normalization onto every future consumer, and the function -already normalizes on the way in. - -### Tasks - -- [ ] Add a post-rounding normalization step covering both `round53` - return paths. -- [ ] Document the mantissa-width postcondition of `decToBin` in its JSDoc. -- [ ] Add proofs for the carry cases: `[18014398509481983n, 0]`, its - negation, and a non-tie carry input; assert `abs(m) < 2^53` and that - the value is unchanged. The check must be on the magnitude — `m < 2^53` - is vacuous for a negative mantissa and would pass against the current - broken result `-9007199254740992n`. -- [ ] `npx tsc`, `fjs t`. - -### Related - -- [GitHub issue #265](https://github.com/functionalscript/functionalscript/issues/265) - — the original report. -- `fjs/types/bigfloat/module.f.mjs` — `round53`, `decToBin`. diff --git a/todo/migrate-typescript-to-mjs.md b/todo/migrate-typescript-to-mjs.md index aefbbd42c..4f21a02a3 100644 --- a/todo/migrate-typescript-to-mjs.md +++ b/todo/migrate-typescript-to-mjs.md @@ -787,10 +787,10 @@ blocking, plus the prose sweep. The remaining items are listed under - [x] Continue upward through the runtime dependency graph in reviewable groups until no authored TypeScript implementation/proof source remains. Done for every module in the migration group: no `.f.ts` is left anywhere. The - `fjs/emergent_testing/scenarios/*.pass.ts` fixtures are still authored - TypeScript that `run.sh` hard-links to `_scenario.proof.ts`, but their - extension is the thing under test rather than an unmigrated module — see - the scenario item under [Remaining after stage 1](#remaining-after-stage-1). + `fjs/emergent_testing/scenarios/*.pass.ts` fixtures were the last authored + TypeScript outside `types.ts` besides `all.test.ts`; they have since been + deleted — see the scenario item under + [Remaining after stage 1](#remaining-after-stage-1). - [x] Translate `.ts` to `.mjs` and `.f.ts` to `.f.mjs`, moving static type information either to JSDoc or to an intentionally separate `types.ts` without weakening public type semantics. @@ -885,22 +885,30 @@ person can re-check rather than re-derive. Counts are as of false`) now emits exactly 96 files: 85 `types.js`, one per authored `types.ts`, and 11 from `fjs/emergent_testing/scenarios` plus `all.test.ts`. It therefore cannot simply be deleted — its remaining - output is the `types.js` whose necessity the item above decides, and the - scenario fixtures the item below decides. Sequence it after both. + output is the `types.js` whose necessity the item above decides, plus + `all.test.js` — the entry point consumers are documented to import. The 11 + scenario-derived files are gone with the fixtures, so the count is now 86. + Sequence it after the `types.js` decision. - [ ] **Then drop the blanket `.gitignore` rule** for generated JavaScript (`.gitignore` line 131). Blocked on the same two: while the emit pass runs, 96 generated `.js` land in the tree and need the blanket ignore. -- [ ] **Decide what happens to the `emergent_testing` scenario fixtures.** - `fjs/emergent_testing/scenarios/*.ts`, `scenarios/all.ts` and - `all.test.ts` are the only authored non-`types.ts` TypeScript left. Their - extension is load-bearing: `run.sh` dispatches on `*.pass.ts` / - `*.fail.ts` and hard-links the scenario to `_scenario.proof.ts` and - `all.ts` to `_all.test.ts`, so what they exercise is `node --test`, - `bun test` and `deno test` executing a **TypeScript** proof natively. - Porting them to `.mjs` would delete that coverage rather than move it, so - this is a decision about whether native-TypeScript execution should still - be tested — keep them, replace the coverage some other way, or drop it. +- [x] **Decide what happens to the `emergent_testing` scenario fixtures.** + Decided: **dropped**. `fjs/emergent_testing/scenarios/` — the nine + fixtures, the `all.ts` shim and `run.sh` — is deleted. Nothing ran it: no + CI job or generated workflow invoked `run.sh`, and its `fjs` branch called + `npm run fst`, a script that no longer exists, so that quarter of the + matrix reported `FAIL` for every fixture. The coverage it provided — + external runners executing a **TypeScript** proof natively — is genuinely + lost rather than moved, which is why the decision is recorded with the + recipe to rebuild it: + [`fjs/emergent_testing/scenarios.md`](../fjs/emergent_testing/scenarios.md). + + That leaves `all.test.ts` as the only authored non-`types.ts` TypeScript + in the repository. It contains no TypeScript syntax, so it could become + `all.test.mjs`; the cost is that consumers are documented to import the + generated `functionalscript/fjs/emergent_testing/all.test.js`, so the + rename is a breaking change to a published entry point. Left open. - [x] **Sweep the remaining stale prose.** Done. The measured set was 88 mentions across 42 `.md` files naming an `X.f.ts` whose `X.f.mjs` now exists (resolving each mention against the tree, excluding `CHANGELOG.md`, @@ -944,9 +952,9 @@ person can re-check rather than re-derive. Counts are as of [`blocked/js-extension-type-annotations.md`](./blocked/js-extension-type-annotations.md) and [`formatter-for-f-js-and-f-ts-files.md`](../fjs/todo/formatter-for-f-js-and-f-ts-files.md). Quoting `shouldLoad`, which still matches `.f.ts`: - [`664-emergent-testing-module-files.md`](../fjs/emergent_testing/todo/664-emergent-testing-module-files.md), - [`205.md`](../fjs/emergent_testing/todo/205.md) and - [`skip-property.md`](../fjs/emergent_testing/todo/skip-property.md). + [`664-emergent-testing-module-files.md`](../fjs/emergent_testing/todo/664-emergent-testing-module-files.md) + and [`skip-property.md`](../fjs/emergent_testing/todo/skip-property.md) + (`205.md`, a third, went with the scenario fixtures). Recording a superseded convention or a completed move: [`028-unit-test-examples-api.md`](../fjs/emergent_testing/todo/028-unit-test-examples-api.md), [`throw-payload-assertions.md`](../fjs/emergent_testing/todo/throw-payload-assertions.md)