diff --git a/docs/internals/glossary.md b/docs/internals/glossary.md index 81683fea..5a8954ed 100644 --- a/docs/internals/glossary.md +++ b/docs/internals/glossary.md @@ -26,7 +26,7 @@ The words this project uses with a fixed meaning, in code, comments, diagnostics | **plot** | a 16 KB region of the heap that hands out places of one width | | **scratch** | a block of working memory: `scratch { }` in a program, or a host's mark and rewind | | **promise** | `no.alloc`, `no.host` or `deterministic`, written after a function's result and proved | -| **profile** | the named, versioned set of rules `deterministic` promises (`kest-det 4`) | +| **profile** | the named, versioned set of rules `deterministic` promises (`kest-det 5`) | | **edition** | the version of the language a program is read in (`2026`) | | **chunk** | one compiled function in bytecode, with its constants, slot count and source origins | | **body** | one concrete function in IR; a generic has one body per set of types | diff --git a/docs/user/determinism.md b/docs/user/determinism.md index f4233b05..1f9e30b6 100644 --- a/docs/user/determinism.md +++ b/docs/user/determinism.md @@ -1,6 +1,6 @@ # Determinism -A replay, a rollback or a lockstep game between two machines needs the same program to compute the same world everywhere. Kest writes that promise down as a versioned profile, `kest-det 4`, and a function promises to keep it with `deterministic`. +A replay, a rollback or a lockstep game between two machines needs the same program to compute the same world everywhere. Kest writes that promise down as a versioned profile, `kest-det 5`, and a function promises to keep it with `deterministic`. ## Three questions @@ -24,6 +24,8 @@ A replay, a rollback or a lockstep game between two machines needs the same prog **Mathematics.** `sin`, `cos`, `pow` and `atan2` in `std.math` are written in Kest out of additions, multiplications and divisions (fdlibm's algorithms, in `std.fdlibm`), so they are the same steps on every machine. They are within an ulp of the C library's for ordinary arguments. `sqrt`, `floor` and `ceil` are host doors that IEEE 754 requires to be exact, and `std.math` declares them `deterministic`. +**Rounding.** `round` in `std.math` is the nearest whole number with a half going up (`round(2.5)` is 3, `round(-2.5)` is -2), for every `f64` and `f32`, a whole number answering itself. It is worked out from `floor` and what is left above it rather than as `floor(value + 0.5)`, whose addition is rounded before `floor` sees it. + **Order.** An array walks by index, a store by place, text by byte, a range from first to last, and a table in the order its puts and removes left, which the same operations always reproduce. **Hashing and randomness.** `hash` is FNV-1a from a fixed start for text and fixed arithmetic for everything else, with nothing taken from the run. `std.random` is a source the program holds and seeds; the same seed gives the same numbers everywhere, and none of its functions call the host. diff --git a/docs/user/editions.md b/docs/user/editions.md index 7874d6c0..b4052cab 100644 --- a/docs/user/editions.md +++ b/docs/user/editions.md @@ -3,7 +3,7 @@ `kest --version` prints five numbers, and each moves for a different reason: ``` -kest 0.0.1, abi 4, json 4, profile kest-det 4, edition 2026 +kest 0.0.1, abi 4, json 4, profile kest-det 5, edition 2026 ``` | Number | What it versions | Read by | @@ -11,7 +11,7 @@ kest 0.0.1, abi 4, json 4, profile kest-det 4, edition 2026 | `kest 0.0.1` | the release, by Semantic Versioning | people | | `abi 4` | the shape of the C API in `include/kest.h` (`KEST_ABI_VERSION`, `kest_abi_version()`) | hosts | | `json 4` | the shape of the JSON every command writes under `--json` (`schema` in each object, `KEST_JSON_SCHEMA`); `lsp` and `dap` speak their own protocols instead | tools | -| `profile kest-det 4` | what `deterministic` promises (`kest_profile()`), see [Determinism](determinism.md) | hosts keeping replays and saves | +| `profile kest-det 5` | what `deterministic` promises (`kest_profile()`), see [Determinism](determinism.md) | hosts keeping replays and saves | | `edition 2026` | the language a program is read in (`KEST_EDITION`) | programs | ## What a program is promised diff --git a/docs/user/getting-started.md b/docs/user/getting-started.md index 4852ec3a..2c9acc75 100644 --- a/docs/user/getting-started.md +++ b/docs/user/getting-started.md @@ -83,7 +83,7 @@ That gives a manifest, a program in `src/main.kest` and a test in `tests/adding. | `source src` | a directory the project's modules live under; one line per directory | | `tests tests` | the directory `kest test` runs | | `kest 0.0.1` | the version the project was written against | -| `profile kest-det 4` | the deterministic profile it is written under, see [Determinism](determinism.md) | +| `profile kest-det 5` | the deterministic profile it is written under, see [Determinism](determinism.md) | | `edition 2026` | the edition of the language it is written in, see [Versions and editions](editions.md) | An unknown name in the manifest is an error rather than a line that is skipped: every command run in the project refuses it with `K0709`, and `kest doctor` says what it read. diff --git a/examples/slice/kest.project b/examples/slice/kest.project index 762fcc2b..51721ce5 100644 --- a/examples/slice/kest.project +++ b/examples/slice/kest.project @@ -5,5 +5,5 @@ source src tests tests kest 0.0.1 -profile kest-det 4 +profile kest-det 5 edition 2026 diff --git a/include/kest.h b/include/kest.h index bebe725b..14dd6525 100644 --- a/include/kest.h +++ b/include/kest.h @@ -55,7 +55,7 @@ // replay or ships a save reads this and writes it down beside them, because a // run under one profile and a run under another are two runs. #define KEST_PROFILE_NAME "kest-det" -#define KEST_PROFILE_VERSION 4 +#define KEST_PROFILE_VERSION 5 // Which edition of the language a program is written in, which is a fifth // thing and the one a program chooses: a change that would stop a program diff --git a/lib/std/math.kest b/lib/std/math.kest index f0a2870f..e0c673f3 100644 --- a/lib/std/math.kest +++ b/lib/std/math.kest @@ -234,17 +234,29 @@ fn acos(value: f32) -> f32? no.alloc deterministic { } // The nearest whole number, with a half going up: `round(2.5)` is 3 and -// `round(-2.5)` is -2, which is what `floor(value + 0.5)` says and what a -// program that wanted anything else would write for itself. +// `round(-2.5)` is -2, which is what a program that wanted anything else would +// write for itself. +// +// What is left above the whole number below is asked rather than half added +// first, because the addition is rounded before `floor` sees it: the `f64` +// just under a half and a half make exactly halfway to 1, which goes up, and +// past 2^52 an odd whole number and a half go to the even one above. What is +// left is exact from nought up and from -1/2 down, and between them it is more +// than a half, which rounding cannot take under one. // // Written here rather than declared. Every `extern` in this module is a // function every host of every program that imports it has to provide, so one // more is a thing every host has to be changed for; what can be built out of // what is already declared is built. fn round(value: f64) -> f64 no.alloc deterministic { - return floor(value + 0.5) + let below = floor(value) + if value - below >= 0.5 { + return below + 1.0 + } + return below } +// An `f32` and a half are added in `f64`, which holds every such sum exactly. fn round(value: f32) -> f32 no.alloc deterministic { return f32(floor(f64(value) + 0.5)) } diff --git a/test/regress/roundnear.kest b/test/regress/roundnear.kest new file mode 100644 index 00000000..0efd74b6 --- /dev/null +++ b/test/regress/roundnear.kest @@ -0,0 +1,80 @@ +// Guards against `round` of an `f64` answering one more than the nearest whole +// number: it was `floor(value + 0.5)`, and the addition is rounded first, so +// the `f64` just under a half answered 1 and an odd whole number past 2^52 +// answered the even one above it. + +module test.regress.roundnear + +import std.math + +// Worked out while running, so nothing here is folded before `round` sees it. +fn rounded(value: f64) -> f64 { + return math.round(value) +} + +fn main() -> i32 { + let under: f64 = 0.49999999999999994 + if rounded(under) != 0.0 || rounded(0.0 - under) != 0.0 { + return 1 + } + let odd: f64 = 4503599627370497.0 + let top: f64 = 9007199254740991.0 + let edge: f64 = 4503599627370496.0 + if rounded(edge - 1.0) != edge - 1.0 || + rounded(edge) != edge || + rounded(0.0 - edge - 1.0) != 0.0 - edge - 1.0 || + rounded(odd) != odd || + rounded(0.0 - odd) != 0.0 - odd || + rounded(top) != top || + rounded(odd + 0.5) != odd + 1.0 { + return 2 + } + // A half goes up, on either side of nought. + if rounded(2.5) != 3.0 || + rounded(0.0 - 2.5) != 0.0 - 2.0 || + rounded(0.5) != 1.0 || + rounded(0.0 - 0.5) != 0.0 || + rounded(1.5) != 2.0 || + rounded(0.0 - 1.5) != 0.0 - 1.0 { + return 3 + } + // Just either side of a half, and just under nought, where what is left + // above the whole number below is more than a half and not exact. + let tiny: f64 = 0.0000000000000000001 + if rounded(2.4999999999999996) != 2.0 || + rounded(2.5000000000000004) != 3.0 || + rounded(0.0 - tiny) != 0.0 || + rounded(0.0 - 0.30000000000000004) != 0.0 || + rounded(0.0 - 0.7) != 0.0 - 1.0 { + return 4 + } + // What is already whole, or not a number at all, answers itself. + let wide: f64 = 1.0e300 + let none_of_them: f64 = 0.0 / 0.0 + if rounded(wide) != wide || + rounded(1.0 / 0.0) != 1.0 / 0.0 || + rounded(0.0 - 1.0 / 0.0) != 0.0 - 1.0 / 0.0 || + math.isNumber(rounded(none_of_them)) { + return 5 + } + // And `f32`, which was right and stays so: an `f32` and a half are added + // in `f64`, which holds every such sum exactly. + let narrow: f32 = 0.49999997 + let whole: f32 = 8388609.0 + if math.round(narrow) != 0.0 || + math.round(f32(0.0) - narrow) != 0.0 || + math.round(whole) != whole || + math.round(f32(0.0) - whole) != f32(0.0) - whole || + math.round(f32(2.5)) != 3.0 || + math.round(f32(0.0 - 2.5)) != 0.0 - 2.0 || + math.round(f32(0.0 - 0.5)) != 0.0 { + return 6 + } + // Nought keeps its sign: the whole number below it is itself, and nothing + // is left above it. The sum it was took nought under nought to nought. + let under_nought: f64 = 0.0 * (0.0 - 1.0) + if 1.0 / rounded(under_nought) > 0.0 || 1.0 / rounded(0.0) < 0.0 { + return 7 + } + return 0 +} diff --git a/tools/fuzz.c b/tools/fuzz.c index b768de2b..5235674c 100644 --- a/tools/fuzz.c +++ b/tools/fuzz.c @@ -1319,7 +1319,7 @@ static const char MANIFEST[] = "# a project\n" "tests tests\n" "\n" "kest 0.0.1\n" - "profile kest-det 4\n" + "profile kest-det 5\n" "edition " KEST_EDITION_STRING "\n"; static const char *const MANIFEST_PIECES[] = {