832375f2ac
All 176 files in the four accumulating directories now use a zero-padded 4-digit counter prefix that reflects creation order (`NNNN-slug.md`). The counter is assigned per directory in strict git-log creation order; ties broken alphabetically by original name. The old `YYYY-MM-DD-` prefix on docs/specs/ and docs/plans/ files is dropped — the date is recoverable from git log and the counter carries the ordering. A file's counter is stable for the life of the file: never reassigned, never reused, never compacted. Deleted files retire their counter; subsequent files do not fill the gap. This is the property that lets cross-references stay literal — refs use the full filename including the counter (`design/contracts/0007-honesty-rule.md`) so they grep cleanly and resolve directly without a glob step. 313 cross-references updated across .md/.rs/.toml/.c/.json files (test pins, include_str! paths, design-INDEX entries, baseline notes, runtime C comments, inter-contract markdown links incl. bare basename and `../models/foo.md` forms). CLAUDE.md gets a new "File-naming convention" section spelling out the rule and rationale. skills/brainstorm/SKILL.md and skills/planner/SKILL.md updated so new spec/plan creation produces counter-prefixed names from the start. The full test suite (cargo test --workspace) passes.
720 lines
25 KiB
Markdown
720 lines
25 KiB
Markdown
# Floats Iteration 1 — Schema Layer (Implementation Plan)
|
|
|
|
> **Parent spec:** `docs/specs/0005-floats.md` (committed
|
|
> `e37366f`, approved 2026-05-10)
|
|
>
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: use `skills/implement`
|
|
> to run this plan. Steps use `- [ ]` checkboxes for tracking.
|
|
|
|
**Goal:** Add the `Literal::Float { bits: u64 }` variant to the AST,
|
|
serialise it as `{"bits":"<16-lowercase-hex>","kind":"float"}` in
|
|
canonical JSON, register `"Float"` in the primitive-name set, and
|
|
keep the workspace compiling by adding exhaustive `Literal::Float`
|
|
arms across all downstream `match Literal { … }` sites with
|
|
named-iteration `unimplemented!` placeholders where later iterations
|
|
own the semantics.
|
|
|
|
**Architecture:** The variant carries a `u64` for the IEEE-754
|
|
binary64 bit pattern. A private `hex_u64` serde helper module on
|
|
`ast.rs` routes the field through the JSON *string* path
|
|
(16-lowercase-hex), bypassing `serde_json::Number` so canonical
|
|
bytes stay bit-stable across `serde_json` versions and so NaN / ±Inf
|
|
are representable (they are not, as JSON numbers). Adding the
|
|
variant breaks Rust's enum exhaustiveness at eight downstream
|
|
`match Literal` sites — each gets either the right semantic arm
|
|
(typecheck → `Type::float()`) or a named-iteration
|
|
`unimplemented!("Floats iter N: <subsystem>")` arm.
|
|
|
|
**Tech Stack:** `ailang-core` (ast.rs, canonical.rs, primitives.rs,
|
|
desugar.rs, pretty.rs); compile-completeness arms in `ailang-check`,
|
|
`ailang-codegen`, `ailang-surface`, `ailang-prose`.
|
|
|
|
---
|
|
|
|
## Files this plan creates or modifies
|
|
|
|
- Modify: `crates/ailang-core/src/ast.rs` — add private `hex_u64`
|
|
serde helper module, `Literal::Float { #[serde(with = "hex_u64")]
|
|
bits: u64 }` variant, `pub fn Type::float() -> Type` constructor.
|
|
- Modify: `crates/ailang-core/src/canonical.rs` — refresh module-doc
|
|
"no floats" comment; add canonical-bytes RED test plus
|
|
bit-stability tests (-0/+0 distinct, NaN/Inf preserved, serde
|
|
round-trip) in the `tests` mod.
|
|
- Modify: `crates/ailang-core/src/primitives.rs` — append `"Float"`
|
|
to both `is_primitive_name` and `primitive_surface_name`; extend
|
|
the `predicate_and_surface_name_agree` lockstep test name list to
|
|
include `"Float"`.
|
|
- Modify: `crates/ailang-core/src/desugar.rs` — extend the
|
|
`build_eq` OR-pattern at line 1061 to include `Literal::Float`.
|
|
- Modify: `crates/ailang-core/src/pretty.rs` — add `Literal::Float
|
|
{ bits } => format!("(float-bits 0x{:016x})", bits)` arm at
|
|
`lit_to_string` line 109.
|
|
- Modify: `crates/ailang-check/src/lib.rs` — add `Literal::Float
|
|
{ .. } => Type::float()` arm at the two `Literal` match sites
|
|
(lines 1697-1700 and 2307-2310).
|
|
- Modify: `crates/ailang-codegen/src/lib.rs` — add `Literal::Float
|
|
{ .. } => unimplemented!("Floats milestone iter 4: codegen")` at
|
|
the three `Literal` match sites (lines 918-928, 1215-1227,
|
|
2357-2361).
|
|
- Modify: `crates/ailang-surface/src/print.rs` — add `Literal::Float
|
|
{ .. } => unimplemented!("Floats milestone iter 2: surface print")`
|
|
at the two `Literal` match sites (lines 564-567 and 580-588).
|
|
- Modify: `crates/ailang-prose/src/lib.rs` — add `Literal::Float
|
|
{ .. } => unimplemented!("Floats milestone iter 5: prose")` at
|
|
line 913-916.
|
|
- Modify: `docs/JOURNAL.md` — append iteration-close entry.
|
|
|
|
---
|
|
|
|
## Task 1: `Literal::Float` variant + canonical hex serialization
|
|
|
|
**Files:**
|
|
- Modify: `crates/ailang-core/src/ast.rs:540-551` (Literal enum) and
|
|
near `Type::str_()` at line 632 (Type::float() constructor)
|
|
- Modify: `crates/ailang-core/src/canonical.rs:105-135` (tests mod)
|
|
- Modify: 8 downstream `Literal` match sites for compile-completeness
|
|
(paths listed above)
|
|
|
|
- [ ] **Step 1: Write the RED canonical-bytes test**
|
|
|
|
In `crates/ailang-core/src/canonical.rs`, inside `mod tests`, append:
|
|
|
|
```rust
|
|
/// Iter 22-floats.1 RED: a `Literal::Float` carries the IEEE-754
|
|
/// binary64 bit pattern as a `u64`; canonical JSON encodes it as
|
|
/// `{"bits":"<16-lowercase-hex>","kind":"float"}` — string path,
|
|
/// NOT through `serde_json::Number` (which is not bit-stable for
|
|
/// floats and cannot represent NaN / ±Inf at all).
|
|
///
|
|
/// Pinned literal: `1.5_f64` → bit pattern `0x3FF8000000000000` →
|
|
/// hex string `"3ff8000000000000"`. Sorted-key order is
|
|
/// `bits` < `kind` lexicographically.
|
|
#[test]
|
|
fn float_literal_canonical_bytes() {
|
|
use crate::ast::Literal;
|
|
let lit = Literal::Float {
|
|
bits: 0x3ff8_0000_0000_0000u64,
|
|
};
|
|
let bytes = to_bytes(&lit);
|
|
let s = std::str::from_utf8(&bytes).unwrap();
|
|
assert_eq!(s, r#"{"bits":"3ff8000000000000","kind":"float"}"#);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails (RED)**
|
|
|
|
Run: `cargo test -p ailang-core float_literal_canonical_bytes`
|
|
|
|
Expected: COMPILE FAIL with rustc error E0599 / E0432 / similar
|
|
naming `Literal::Float` (the variant does not yet exist).
|
|
|
|
- [ ] **Step 3: Add the `hex_u64` serde helper module to `ast.rs`**
|
|
|
|
In `crates/ailang-core/src/ast.rs`, before the `Literal` enum
|
|
declaration (around line 536), add:
|
|
|
|
```rust
|
|
/// Private serde helper: emits a `u64` as a 16-character lowercase
|
|
/// hex JSON string and parses the same shape back. The
|
|
/// 16-character invariant covers the full `u64` range zero-padded
|
|
/// (`format!("{:016x}", 0u64) == "0000000000000000"`,
|
|
/// `format!("{:016x}", u64::MAX) == "ffffffffffffffff"`). Used by
|
|
/// [`Literal::Float`] so float bit patterns flow through the
|
|
/// canonical-JSON *string* path — guaranteeing bit stability across
|
|
/// `serde_json` versions and surfacing NaN / ±Inf, which JSON
|
|
/// numbers cannot represent.
|
|
mod hex_u64 {
|
|
use serde::{de::Error as DeError, Deserialize, Deserializer, Serializer};
|
|
|
|
pub fn serialize<S: Serializer>(value: &u64, s: S) -> Result<S::Ok, S::Error> {
|
|
s.serialize_str(&format!("{:016x}", value))
|
|
}
|
|
|
|
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<u64, D::Error> {
|
|
let s = <&str>::deserialize(d)?;
|
|
if s.len() != 16 {
|
|
return Err(D::Error::custom(format!(
|
|
"Float bits: expected 16 hex chars, got {}",
|
|
s.len()
|
|
)));
|
|
}
|
|
u64::from_str_radix(s, 16).map_err(D::Error::custom)
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Add the `Literal::Float` variant**
|
|
|
|
In `crates/ailang-core/src/ast.rs`, replace the `Literal` enum
|
|
declaration:
|
|
|
|
```rust
|
|
/// A literal value.
|
|
///
|
|
/// The JSON discriminator is the `kind` field. `Unit` carries no
|
|
/// payload and serializes as `{"kind":"unit"}`. `Float` carries a
|
|
/// `u64` IEEE-754 binary64 bit pattern, serialized via the
|
|
/// [`hex_u64`] helper as a 16-character lowercase hex string —
|
|
/// canonical-JSON bytes therefore stay bit-stable for hashing and
|
|
/// can represent NaN / ±Inf (which JSON numbers cannot).
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(tag = "kind", rename_all = "lowercase")]
|
|
pub enum Literal {
|
|
/// Signed 64-bit integer.
|
|
Int { value: i64 },
|
|
/// Boolean.
|
|
Bool { value: bool },
|
|
/// UTF-8 string.
|
|
Str { value: String },
|
|
/// The unit value `()`.
|
|
Unit,
|
|
/// IEEE-754 binary64 (LLVM `double`) carried as its bit pattern.
|
|
/// See [`hex_u64`] for the serialization rationale.
|
|
Float {
|
|
#[serde(with = "hex_u64")]
|
|
bits: u64,
|
|
},
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Add the `Type::float()` convenience constructor**
|
|
|
|
In `crates/ailang-core/src/ast.rs`, in the `impl Type { … }` block
|
|
(immediately after `pub fn str_()` at line 632), append:
|
|
|
|
```rust
|
|
/// Convenience constructor for `Float` (no args). IEEE-754
|
|
/// binary64. Parallel to [`Type::int`] / [`Type::bool_`].
|
|
pub fn float() -> Type {
|
|
Type::Con { name: "Float".into(), args: vec![] }
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 6: Add compile-completeness arm in `desugar.rs`**
|
|
|
|
In `crates/ailang-core/src/desugar.rs`, line 1061, replace the
|
|
existing OR-pattern:
|
|
|
|
```rust
|
|
Literal::Int { .. } | Literal::Bool { .. } | Literal::Str { .. } => Term::App {
|
|
```
|
|
|
|
with:
|
|
|
|
```rust
|
|
Literal::Int { .. }
|
|
| Literal::Bool { .. }
|
|
| Literal::Str { .. }
|
|
| Literal::Float { .. } => Term::App {
|
|
```
|
|
|
|
- [ ] **Step 7: Add compile-completeness arm in `pretty.rs`**
|
|
|
|
In `crates/ailang-core/src/pretty.rs`, line 109's `lit_to_string`,
|
|
add the `Literal::Float` arm before the closing brace:
|
|
|
|
```rust
|
|
fn lit_to_string(l: &Literal) -> String {
|
|
match l {
|
|
Literal::Int { value } => value.to_string(),
|
|
Literal::Bool { value } => value.to_string(),
|
|
Literal::Str { value } => {
|
|
// serde_json escapes for us; the result is a valid
|
|
// JSON string literal, which is enough as our canonical form.
|
|
serde_json::to_string(value).unwrap()
|
|
}
|
|
Literal::Unit => "()".to_string(),
|
|
Literal::Float { bits } => format!("(float-bits 0x{:016x})", bits),
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 8: Add compile-completeness arms in `crates/ailang-check/src/lib.rs`**
|
|
|
|
Two sites. At line 1697-1700:
|
|
|
|
```rust
|
|
Literal::Int { .. } => Type::int(),
|
|
Literal::Bool { .. } => Type::bool_(),
|
|
Literal::Str { .. } => Type::str_(),
|
|
Literal::Unit => Type::unit(),
|
|
Literal::Float { .. } => Type::float(),
|
|
```
|
|
|
|
At line 2307-2310 (same shape — typecheck literal → type):
|
|
|
|
```rust
|
|
Literal::Int { .. } => Type::int(),
|
|
Literal::Bool { .. } => Type::bool_(),
|
|
Literal::Str { .. } => Type::str_(),
|
|
Literal::Unit => Type::unit(),
|
|
Literal::Float { .. } => Type::float(),
|
|
```
|
|
|
|
- [ ] **Step 9: Add compile-completeness arms in `crates/ailang-codegen/src/lib.rs`**
|
|
|
|
Three sites. At line 918-928 (constant lowering):
|
|
|
|
```rust
|
|
let (val_ty, val) = match lit {
|
|
Literal::Int { value } => ("i64".to_string(), value.to_string()),
|
|
Literal::Bool { value } => (
|
|
"i1".to_string(),
|
|
if *value { "true".into() } else { "false".into() },
|
|
),
|
|
Literal::Unit => ("i8".to_string(), "0".to_string()),
|
|
Literal::Str { value } => {
|
|
let g = self.intern_string("str", value);
|
|
("ptr".to_string(), format!("@{g}"))
|
|
}
|
|
Literal::Float { .. } => unimplemented!("Floats milestone iter 4: codegen"),
|
|
};
|
|
```
|
|
|
|
At line 1215-1227 (term lowering):
|
|
|
|
```rust
|
|
Term::Lit { lit } => Ok(match lit {
|
|
Literal::Int { value } => (value.to_string(), "i64".into()),
|
|
Literal::Bool { value } => (
|
|
if *value { "true".into() } else { "false".into() },
|
|
"i1".into(),
|
|
),
|
|
Literal::Str { value } => {
|
|
// Create global constant; in opaque-pointer LLVM,
|
|
// `@name` is directly a valid `ptr`.
|
|
let g = self.intern_string("str", value);
|
|
(format!("@{g}"), "ptr".into())
|
|
}
|
|
Literal::Unit => ("0".into(), "i8".into()),
|
|
Literal::Float { .. } => unimplemented!("Floats milestone iter 4: codegen"),
|
|
}),
|
|
```
|
|
|
|
At line 2357-2361 (synth_with_extras → Type — semantic arm, not
|
|
placeholder):
|
|
|
|
```rust
|
|
Term::Lit { lit } => Ok(match lit {
|
|
Literal::Int { .. } => Type::int(),
|
|
Literal::Bool { .. } => Type::bool_(),
|
|
Literal::Str { .. } => Type::str_(),
|
|
Literal::Unit => Type::unit(),
|
|
Literal::Float { .. } => Type::float(),
|
|
}),
|
|
```
|
|
|
|
- [ ] **Step 10: Add compile-completeness arms in `crates/ailang-surface/src/print.rs`**
|
|
|
|
Two sites. At line 564-567:
|
|
|
|
```rust
|
|
fn write_lit(out: &mut String, lit: &Literal) {
|
|
match lit {
|
|
Literal::Int { value } => out.push_str(&value.to_string()),
|
|
Literal::Bool { value } => out.push_str(if *value { "true" } else { "false" }),
|
|
Literal::Str { value } => write_string_lit(out, value),
|
|
Literal::Unit => out.push_str("(lit-unit)"),
|
|
Literal::Float { .. } => unimplemented!("Floats milestone iter 2: surface print"),
|
|
}
|
|
}
|
|
```
|
|
|
|
At line 580-588 (inside `write_pattern` Pattern::Lit arm):
|
|
|
|
```rust
|
|
match lit {
|
|
Literal::Int { value } => out.push_str(&value.to_string()),
|
|
Literal::Bool { value } => out.push_str(if *value { "true" } else { "false" }),
|
|
Literal::Str { value } => write_string_lit(out, value),
|
|
Literal::Unit => {
|
|
// Unit is not a valid pat-lit; fall back to a bare unit-lit
|
|
// form. This is unreachable for any well-formed AST that
|
|
// came through the typechecker.
|
|
out.push_str("(lit-unit)");
|
|
}
|
|
Literal::Float { .. } => unimplemented!("Floats milestone iter 2: surface print"),
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 11: Add compile-completeness arm in `crates/ailang-prose/src/lib.rs`**
|
|
|
|
At line 913-916:
|
|
|
|
```rust
|
|
Literal::Int { value } => out.push_str(&value.to_string()),
|
|
Literal::Bool { value } => out.push_str(if *value { "true" } else { "false" }),
|
|
Literal::Str { value } => write_string_lit(out, value),
|
|
Literal::Unit => out.push_str("()"),
|
|
Literal::Float { .. } => unimplemented!("Floats milestone iter 5: prose"),
|
|
```
|
|
|
|
- [ ] **Step 12: Verify the workspace builds**
|
|
|
|
Run: `cargo build --workspace`
|
|
Expected: clean build, zero new warnings.
|
|
|
|
- [ ] **Step 13: Verify the RED test now passes (GREEN)**
|
|
|
|
Run: `cargo test -p ailang-core float_literal_canonical_bytes`
|
|
Expected: PASS — exactly one test reported, GREEN.
|
|
|
|
- [ ] **Step 14: Verify no regression across the workspace**
|
|
|
|
Run: `cargo test --workspace`
|
|
Expected: all existing tests PASS. The pre-existing `def_hash`
|
|
regression tests in `crates/ailang-core/src/hash.rs` (which pin
|
|
`db33f57cb329935e` for `sum.sum` and `b082192bd0c99202` for
|
|
`IntList`) MUST stay GREEN — adding `Literal::Float` to the enum
|
|
must not change the canonical bytes of any pre-existing fixture
|
|
(neither `sum.ail.json` nor `list.ail.json` contains a Float
|
|
literal).
|
|
|
|
- [ ] **Step 15: Commit**
|
|
|
|
```bash
|
|
git add crates/ailang-core/src/ast.rs \
|
|
crates/ailang-core/src/canonical.rs \
|
|
crates/ailang-core/src/desugar.rs \
|
|
crates/ailang-core/src/pretty.rs \
|
|
crates/ailang-check/src/lib.rs \
|
|
crates/ailang-codegen/src/lib.rs \
|
|
crates/ailang-surface/src/print.rs \
|
|
crates/ailang-prose/src/lib.rs
|
|
git commit -m "floats iter 1.1: Literal::Float variant + canonical hex serialization"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: Register `"Float"` as a primitive type name
|
|
|
|
**Files:**
|
|
- Modify: `crates/ailang-core/src/primitives.rs` (entire file is 52
|
|
LoC; both functions plus the lockstep test).
|
|
|
|
- [ ] **Step 1: Extend the lockstep test (RED)**
|
|
|
|
In `crates/ailang-core/src/primitives.rs`, modify the test name list
|
|
at line 44 to include `"Float"`:
|
|
|
|
```rust
|
|
#[test]
|
|
fn predicate_and_surface_name_agree() {
|
|
for name in ["Int", "Bool", "Str", "Unit", "Float", "List", "Foo", "", "int"] {
|
|
assert_eq!(
|
|
is_primitive_name(name),
|
|
primitive_surface_name(name).is_some(),
|
|
"lockstep violation for {name:?}"
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails (RED)**
|
|
|
|
Run: `cargo test -p ailang-core predicate_and_surface_name_agree`
|
|
Expected: FAIL with `lockstep violation for "Float"` —
|
|
`is_primitive_name("Float") == false` but the test now expects it
|
|
to be `true` AND requires `primitive_surface_name("Float")` to be
|
|
`Some(_)`. The exact assertion that fires: the `assert_eq!` line
|
|
(`false != true.is_some()` evaluates `false != false = false` —
|
|
wait, both are `false`, so this would not actually fail until we
|
|
also flip the test expectation; instead, the test design is "they
|
|
must AGREE", and since both are currently `false` for `"Float"`,
|
|
agreement still holds.
|
|
|
|
Therefore the RED test must be tightened: append a line that
|
|
*explicitly asserts* `"Float"` is recognised. Append after the
|
|
loop:
|
|
|
|
```rust
|
|
// Float is a primitive (Floats milestone, iter 1).
|
|
assert!(is_primitive_name("Float"), "Float must be a primitive");
|
|
assert_eq!(
|
|
primitive_surface_name("Float"),
|
|
Some("Float"),
|
|
"Float surface name must be \"Float\""
|
|
);
|
|
```
|
|
|
|
Re-run: `cargo test -p ailang-core predicate_and_surface_name_agree`
|
|
Expected: FAIL on `assert!(is_primitive_name("Float"))`.
|
|
|
|
- [ ] **Step 3: Add `"Float"` to both functions (GREEN)**
|
|
|
|
In `crates/ailang-core/src/primitives.rs`, line 17:
|
|
|
|
```rust
|
|
pub fn is_primitive_name(name: &str) -> bool {
|
|
matches!(name, "Int" | "Bool" | "Str" | "Unit" | "Float")
|
|
}
|
|
```
|
|
|
|
In `crates/ailang-core/src/primitives.rs`, line 24:
|
|
|
|
```rust
|
|
pub fn primitive_surface_name(name: &str) -> Option<&'static str> {
|
|
match name {
|
|
"Int" => Some("Int"),
|
|
"Bool" => Some("Bool"),
|
|
"Str" => Some("Str"),
|
|
"Unit" => Some("Unit"),
|
|
"Float" => Some("Float"),
|
|
_ => None,
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Verify the test now passes (GREEN)**
|
|
|
|
Run: `cargo test -p ailang-core predicate_and_surface_name_agree`
|
|
Expected: PASS.
|
|
|
|
Run also: `cargo test --workspace`
|
|
Expected: all green. (Adding a primitive name should not change any
|
|
existing fixture — no existing fixture references `"Float"` as a
|
|
type constructor.)
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add crates/ailang-core/src/primitives.rs
|
|
git commit -m "floats iter 1.2: register Float as a primitive type name"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: Bit-stability tests for `Literal::Float`
|
|
|
|
**Files:**
|
|
- Modify: `crates/ailang-core/src/canonical.rs` `tests` mod —
|
|
append four tests pinning the spec's IEEE-bit-stability
|
|
guarantees (A1 + A5).
|
|
|
|
- [ ] **Step 1: Add the four bit-stability tests**
|
|
|
|
In `crates/ailang-core/src/canonical.rs`, inside `mod tests`,
|
|
append:
|
|
|
|
```rust
|
|
/// A5 guarantee: -0.0 and +0.0 are distinct bit patterns and
|
|
/// therefore distinct Form-A literals (they hash distinctly even
|
|
/// though IEEE-`==` will report them equal at the value level
|
|
/// when arithmetic comparison ships).
|
|
#[test]
|
|
fn negative_zero_and_positive_zero_serialise_distinctly() {
|
|
use crate::ast::Literal;
|
|
let pos = Literal::Float { bits: 0x0u64 };
|
|
let neg = Literal::Float { bits: 0x8000_0000_0000_0000u64 };
|
|
let pos_bytes = to_bytes(&pos);
|
|
let neg_bytes = to_bytes(&neg);
|
|
assert_ne!(pos_bytes, neg_bytes, "+0 and -0 must hash distinctly");
|
|
assert_eq!(
|
|
std::str::from_utf8(&pos_bytes).unwrap(),
|
|
r#"{"bits":"0000000000000000","kind":"float"}"#
|
|
);
|
|
assert_eq!(
|
|
std::str::from_utf8(&neg_bytes).unwrap(),
|
|
r#"{"bits":"8000000000000000","kind":"float"}"#
|
|
);
|
|
}
|
|
|
|
/// A1 guarantee: a NaN bit pattern survives canonicalisation
|
|
/// without collapse to JSON `null` (which is what the `serde_json`
|
|
/// number path does for non-finite floats — and the reason
|
|
/// `Literal::Float` routes through the *string* path).
|
|
#[test]
|
|
fn nan_bits_preserved() {
|
|
use crate::ast::Literal;
|
|
let qnan = Literal::Float { bits: 0x7ff8_0000_0000_0000u64 };
|
|
let bytes = to_bytes(&qnan);
|
|
assert_eq!(
|
|
std::str::from_utf8(&bytes).unwrap(),
|
|
r#"{"bits":"7ff8000000000000","kind":"float"}"#
|
|
);
|
|
}
|
|
|
|
/// A1 guarantee: ±Inf bit patterns survive canonicalisation —
|
|
/// JSON numbers cannot represent infinity at all, the string path
|
|
/// must.
|
|
#[test]
|
|
fn inf_bits_preserved() {
|
|
use crate::ast::Literal;
|
|
let pos_inf = Literal::Float { bits: 0x7ff0_0000_0000_0000u64 };
|
|
let neg_inf = Literal::Float { bits: 0xfff0_0000_0000_0000u64 };
|
|
assert_eq!(
|
|
std::str::from_utf8(&to_bytes(&pos_inf)).unwrap(),
|
|
r#"{"bits":"7ff0000000000000","kind":"float"}"#
|
|
);
|
|
assert_eq!(
|
|
std::str::from_utf8(&to_bytes(&neg_inf)).unwrap(),
|
|
r#"{"bits":"fff0000000000000","kind":"float"}"#
|
|
);
|
|
}
|
|
|
|
/// Round-trip: serialise then deserialise via `serde_json` — bits
|
|
/// preserved bit-for-bit.
|
|
#[test]
|
|
fn float_literal_serde_roundtrip() {
|
|
use crate::ast::Literal;
|
|
let cases = [
|
|
0x0u64,
|
|
0x8000_0000_0000_0000u64,
|
|
0x3ff8_0000_0000_0000u64, // 1.5
|
|
0x7ff8_0000_0000_0000u64, // qNaN
|
|
0x7ff0_0000_0000_0000u64, // +Inf
|
|
0xfff0_0000_0000_0000u64, // -Inf
|
|
0xffff_ffff_ffff_ffffu64, // saturated
|
|
];
|
|
for &bits in &cases {
|
|
let lit = Literal::Float { bits };
|
|
let json = serde_json::to_string(&lit).unwrap();
|
|
let back: Literal = serde_json::from_str(&json).unwrap();
|
|
match back {
|
|
Literal::Float { bits: got } => assert_eq!(
|
|
got, bits,
|
|
"round-trip lost bits for {:#018x}: json={}",
|
|
bits, json
|
|
),
|
|
other => panic!("expected Literal::Float, got {:?}", other),
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Verify all four tests PASS (no impl change needed)**
|
|
|
|
Run: `cargo test -p ailang-core --test '*' canonical -- \
|
|
negative_zero_and_positive_zero_serialise_distinctly \
|
|
nan_bits_preserved inf_bits_preserved \
|
|
float_literal_serde_roundtrip`
|
|
|
|
Or simpler:
|
|
Run: `cargo test -p ailang-core canonical::tests`
|
|
Expected: all canonical tests GREEN, including the four new ones.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add crates/ailang-core/src/canonical.rs
|
|
git commit -m "floats iter 1.3: bit-stability tests for Literal::Float"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: Refresh `canonical.rs` module-doc comment
|
|
|
|
**Files:**
|
|
- Modify: `crates/ailang-core/src/canonical.rs:13-14` (module-level
|
|
doc comment).
|
|
|
|
- [ ] **Step 1: Replace the "no floats" sentence**
|
|
|
|
In `crates/ailang-core/src/canonical.rs`, lines 12-14, replace:
|
|
|
|
```
|
|
//! number-formatting follows whatever `serde_json::Number::to_string`
|
|
//! does, which is fine for the AILang AST (integers and short strings;
|
|
//! no floats).
|
|
```
|
|
|
|
with:
|
|
|
|
```
|
|
//! Number formatting follows whatever `serde_json::Number::to_string`
|
|
//! does — used only for the AST integer literals. Float literals do
|
|
//! not go through the number path; [`crate::ast::Literal::Float`]
|
|
//! carries its IEEE-754 bit pattern as a `u64` and serialises via a
|
|
//! 16-character lowercase hex *string*, which the canonical writer
|
|
//! treats like any other string. The string path is what makes float
|
|
//! canonical bytes bit-stable across `serde_json` versions and what
|
|
//! lets NaN / ±Inf round-trip at all (they cannot, as JSON numbers).
|
|
```
|
|
|
|
- [ ] **Step 2: Verify rustdoc still builds**
|
|
|
|
Run: `cargo doc --no-deps -p ailang-core 2>&1 | head -40`
|
|
Expected: no NEW warnings on `canonical.rs` (the module's
|
|
pre-existing warning baseline must not increase).
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add crates/ailang-core/src/canonical.rs
|
|
git commit -m "floats iter 1.4: refresh canonical.rs 'no floats' doc comment"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: Iteration close — JOURNAL entry
|
|
|
|
**Files:**
|
|
- Modify: `docs/JOURNAL.md` — append entry at top under today's
|
|
date heading.
|
|
|
|
- [ ] **Step 1: Append the iteration-close entry**
|
|
|
|
In `docs/JOURNAL.md`, append a new section under today's date:
|
|
|
|
```markdown
|
|
### Floats milestone — iter 1: schema layer
|
|
|
|
Added `Literal::Float { bits: u64 }` as the fifth `Literal` variant.
|
|
Bits are an IEEE-754 binary64 bit pattern, serialised as a
|
|
16-character lowercase hex *string* (`{"bits":"<hex>","kind":"float"}`)
|
|
via a private `hex_u64` serde helper on `ast.rs`. Routing through
|
|
the JSON string path bypasses `serde_json::Number::to_string` (not
|
|
bit-stable across `serde_json` versions for floats) and lets NaN /
|
|
±Inf survive canonicalisation at all (they collapse to `null` as
|
|
JSON numbers).
|
|
|
|
Adding the variant broke Rust enum exhaustiveness at eight
|
|
downstream `match Literal { … }` sites. Each got either the
|
|
permanent semantic arm (`ailang-check` typecheck:
|
|
`Literal::Float { .. } => Type::float()`) or a named-iteration
|
|
`unimplemented!("Floats milestone iter N: <subsystem>")` arm
|
|
(`ailang-codegen` → iter 4, `ailang-surface` print → iter 2,
|
|
`ailang-prose` → iter 5). The arms are honest about which
|
|
iteration owns the semantics.
|
|
|
|
`Float` is now registered as a primitive type name in
|
|
`primitives.rs`; the lockstep test is extended to cover it.
|
|
|
|
The four bit-stability tests pin the A1 / A5 spec guarantees:
|
|
`-0` ≠ `+0` at the canonical-bytes level, NaN bits preserved,
|
|
±Inf bits preserved, serde round-trip is bit-exact for the
|
|
saturating boundary values.
|
|
|
|
Pre-existing `def_hash` regression hashes (`db33f57cb329935e` /
|
|
`b082192bd0c99202`) stay GREEN — adding a Literal variant does
|
|
not perturb any pre-existing canonical bytes.
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add docs/JOURNAL.md
|
|
git commit -m "floats iter 1: JOURNAL — iteration close"
|
|
```
|
|
|
|
---
|
|
|
|
## Iteration acceptance
|
|
|
|
- [ ] `cargo build --workspace` is clean.
|
|
- [ ] `cargo test --workspace` is GREEN (no pre-existing test
|
|
regressed; the pre-existing `def_hash` regression hashes
|
|
stay bit-identical).
|
|
- [ ] `cargo test -p ailang-core float_literal_canonical_bytes` and
|
|
the four bit-stability tests are GREEN.
|
|
- [ ] `cargo test -p ailang-core predicate_and_surface_name_agree`
|
|
is GREEN with `"Float"` in the recognised set.
|
|
- [ ] `crates/ailang-core/src/canonical.rs:14` no longer says
|
|
"no floats".
|
|
- [ ] JOURNAL entry committed.
|
|
- [ ] No file touched outside the nine listed in the file map above.
|
|
|
|
When all five tasks are committed and the acceptance checklist is
|
|
green, hand back to the orchestrator. Iteration 2 (Surface lex /
|
|
parse / print round-trip) is the next dispatch.
|