100%

Section 05: Float Narrowing Pipeline

Context: Float narrowing is much more constrained than integer narrowing because floating-point precision is non-linear. The set of values exactly representable in f32 is a strict subset of f64. Narrowing is only safe when:

  1. All literal values are exactly representable in f32
  2. No arithmetic is performed on the value between store and load (storage-only)
  3. The fptruncfpext roundtrip is lossless for every observed value

In practice, this means float narrowing is mostly useful for:

  • Struct fields that store f32-exact constants (0.0, 1.0, 0.5, integer-valued floats up to 2^24)
  • Pure storage/retrieval without arithmetic (data transfer patterns)
  • Graphics/audio struct fields where all values happen to be f32-exact

Spec authority: Annex E (System Considerations) explicitly permits float narrowing: “The compiler may use a narrower machine representation when it can prove no precision loss.” The semantic contract is IEEE 754 double precision — any narrowed path must produce bit-identical results to the canonical f64 path for all language-level operations.

Reference implementations:

  • LLVM InstCombineCasts.cpp: canEvaluateTruncated() — checks if fptrunc is lossless
  • GCC convert.cc: Excess precision handling for C11 semantics

Depends on: §03 (range analysis infrastructure — fixpoint pattern and RangeAnalysisConfig). §05 does NOT consume §03’s ValueRange lattice (which is integer-only). Instead, §05 defines its own FloatRange lattice and collection pass that operates on the same ArcFunction IR.

Crate dependency: Same as §04 — ori_repr reads ArcFunction from ori_arc. The narrowing/float.rs module lives alongside narrowing/int.rs in compiler/ori_repr/src/narrowing/.

Scope: storage-only narrowing (Phase A). This section implements float narrowing for struct/tuple fields that are stored and loaded but never used in arithmetic. Arithmetic narrowing (computing in f32) is a semantic change requiring explicit opt-in — it is NOT part of this section. The plan conservatively treats any arithmetic result as non-narrowable.

Downstream contracts:

  • §05 → §06 (Struct Layout): Same interface contract as §04 → §06. §05 writes only FieldRepr.repr (narrowed to Float { F32 }). It does NOT compute FieldRepr.offset, StructRepr.size, or StructRepr.align — those are §06’s exclusive responsibility. §06 reads the narrowed repr values (now potentially 4-byte f32 instead of 8-byte f64) to compute correct field sizes and alignment-optimal reordering.
  • §05 → §07 (Enum Repr): Float narrowing may produce f32-typed fields in structs used as enum variant payloads. f32 NaN bit patterns (quiet NaN: 0x7FC000000x7FFFFFFF and 0xFFC000000xFFFFFFFF) are technically invalid “values” but IEEE 754 semantics make them complex to use as niches. §07’s find_niches() MUST return vec![] for MachineRepr::Float { width: F32 } fields — no NaN-based niches for f32 fields. §05 documents this here; §07 implements the guard.
  • §05 → §01 (float_width query): §05 writes Float { width: F32 } into ReprPlan struct field entries. The existing float_width(idx) query (plan/query.rs:81, default F64) is type-level, NOT field-level — it reflects the canonical float type. §05’s narrowing is field-level (inside MachineRepr::Struct field entries), so it does NOT change the top-level float_width() return for Tag::Float. Codegen reads the narrowed width from FieldRepr.repr, not from float_width().

Interaction with §04 (Integer Narrowing): A single struct may have BOTH integer fields narrowed by §04 AND float fields narrowed by §05. The combined result is a MachineRepr::Struct with a mix of narrowed int fields and narrowed float fields. §04’s try_lower_narrowed_aggregate() in layout_resolver.rs:318 has two relevant guards: (1) has_narrowed only checks for Int { width != I64 } — it does NOT detect Float { F32 }, so a struct with ONLY narrowed float fields would fall through to the TypeInfoStore named-struct path; (2) the all_scalar_int variable (misleadingly named) already accepts MachineRepr::Float { .. } in its match — so the “all fields are scalar” guard does NOT block float fields. §05 must extend the has_narrowed detection to include float narrowing. This is documented in §05.4.


05.1 Precision Analysis

TDD order: Write unit tests for is_f32_exact() and FloatRange lattice BEFORE implementing the functions. The tests listed in §05.5 under “is_f32_exact() correctly identifies f32-representable f64 values” and “FloatRange lattice operations” are the Phase 1 test matrix. Verify they fail (compile error / missing function), then implement, then verify they pass unchanged. Run in both debug and release: timeout 150 cargo test -p ori_repr -- float for each.

File(s): compiler/ori_repr/src/narrowing/float.rs (NEW — must be created and registered in narrowing/mod.rs)

Setup required:

  1. Create compiler/ori_repr/src/narrowing/float.rs
  2. Add pub mod float; to compiler/ori_repr/src/narrowing/mod.rs (currently has pub mod abi; pub mod int; pub mod overflow; at lines 22-24, and #[cfg(test)] mod tests; at line 26-27)
  3. Update narrowing/mod.rs module doc (//! at line 1) to mention the float module alongside the existing three modules
  4. Add pub use narrowing::float::... to compiler/ori_repr/src/lib.rs as needed (follow the pattern of existing pub use narrowing::abi::... at line 46-48)

Note: f64 does not implement Eq or Hash. FloatRange must use u64 bit representation (f64::to_bits()) for any variant that needs Eq/Hash, or use PartialEq only (no map key usage). Since FloatRange is used in a field-summary table keyed by (Idx, u32), the range itself does NOT need to be a map key — PartialEq is sufficient.

  • Define FloatRange lattice:

    /// Float precision lattice for field-level narrowing.
    ///
    /// Unlike `ValueRange` (integer intervals), `FloatRange` tracks whether
    /// ALL observed values at a field position are f32-exact, NOT an interval
    /// of float values. This is because f32-exactness is a property of
    /// individual values, not ranges — `[0.0, 1.0]` as a range contains
    /// non-f32-exact values like `0.1`, even though the endpoints are f32-exact.
    #[derive(Debug, Clone, Copy, PartialEq)]
    pub enum FloatRange {
        /// No observations yet (narrowable — no conflicting evidence).
        /// Joins with any other variant by taking the other variant.
        Bottom,
        /// All observed values are exactly representable in f32.
        F32Exact,
        /// At least one observed value is NOT f32-exact → keep f64.
        Top,
    }
  • Implement FloatRange::join():

    impl FloatRange {
        /// Lattice join: widen precision evidence.
        pub fn join(self, other: Self) -> Self {
            match (self, other) {
                (FloatRange::Bottom, x) | (x, FloatRange::Bottom) => x,
                (FloatRange::Top, _) | (_, FloatRange::Top) => FloatRange::Top,
                (FloatRange::F32Exact, FloatRange::F32Exact) => FloatRange::F32Exact,
            }
        }
    }
  • Implement f32 exactness check:

    /// Check if an f64 value is exactly representable as f32.
    ///
    /// The roundtrip `f64 → f32 → f64` is lossless iff the value has no
    /// precision beyond f32's 24-bit significand. NaN is excluded because
    /// NaN payload bits may not survive the roundtrip. Infinities ARE
    /// f32-exact (positive and negative infinity have the same bit
    /// pattern in f32 and f64). Negative zero IS f32-exact.
    ///
    /// f64 values outside f32 range (magnitude > f32::MAX ≈ 3.4e38) cast
    /// to f32::INFINITY, so the roundtrip produces INFINITY ≠ original.
    /// These are correctly rejected by the equality check.
    ///
    /// f64 subnormals that are too small for f32's subnormal range (below
    /// f32::MIN_POSITIVE * 2^-23 ≈ 1.4e-45) flush to zero in f32, so the
    /// roundtrip produces 0.0 ≠ original. These are correctly rejected.
    pub fn is_f32_exact(value: f64) -> bool {
        if value.is_nan() {
            return false;
        }
        let as_f32 = value as f32;
        let roundtripped = as_f32 as f64;
        roundtripped == value
    }
  • Implement FloatRange::observe() for collecting evidence:

    impl FloatRange {
        /// Observe a float literal value and update the range.
        pub fn observe(self, value: f64) -> Self {
            if is_f32_exact(value) {
                self.join(FloatRange::F32Exact)
            } else {
                FloatRange::Top
            }
        }
    
        /// Observe an arithmetic result (conservatively non-narrowable).
        pub fn observe_arithmetic(self) -> Self {
            FloatRange::Top
        }
    }

05.2 Float Range Collection

TDD order: Write unit tests for FloatFieldSummaryTable (observe/get) and collect_float_field_summaries() BEFORE implementing. Test: empty table returns Bottom; single f32-exact observation → F32Exact; mixed observations → Top; non-float fields ignored. Verify they fail, then implement, then verify they pass unchanged.

File(s): compiler/ori_repr/src/narrowing/float.rs

Context: §03’s FieldSummaryTable and update_field_summaries() only track integer-typed fields (gated by is_int_typed() in field_summary.rs:22). Float narrowing needs an analogous collection pass that scans Construct instructions for float-typed field arguments and checks whether those arguments are f32-exact literals.

Design choice: Rather than modifying §03’s integer-only infrastructure, §05 builds a parallel FloatFieldSummaryTable that runs after range analysis. This keeps §03’s integer lattice clean and avoids polluting the fixpoint loop with float-specific logic. The float pass is simpler because it only needs to check literal values — it does NOT need a fixpoint loop (literal values are known statically from LitValue::Float).

  • Define FloatFieldSummaryTable:

    /// Aggregates float precision evidence per (type, field) pair.
    ///
    /// Scans `Construct` sites for float-typed fields and checks whether
    /// ALL values stored at each position are f32-exact literals.
    /// Mirrors `FieldSummaryTable` (`range/field_summary.rs:28`) but for
    /// float precision instead of integer ranges.
    #[derive(Debug)]
    pub struct FloatFieldSummaryTable {
        field_ranges: FxHashMap<(Idx, u32), FloatRange>,
    }
    
    impl FloatFieldSummaryTable {
        pub fn new() -> Self { Self { field_ranges: FxHashMap::default() } }
    
        /// Join a float precision observation for a (type, field) pair.
        pub fn observe(&mut self, type_idx: Idx, field: u32, range: FloatRange) {
            self.field_ranges
                .entry((type_idx, field))
                .and_modify(|existing| *existing = existing.join(range))
                .or_insert(range);
        }
    
        /// Query the float precision for a (type, field) pair.
        /// Returns `Bottom` (no observations) if no evidence has been collected.
        pub fn get(&self, type_idx: Idx, field: u32) -> FloatRange {
            self.field_ranges
                .get(&(type_idx, field))
                .copied()
                .unwrap_or(FloatRange::Bottom)
        }
    }
  • Implement collect_float_field_summaries(): Follow the pattern of update_field_summaries() in range/field_summary.rs:176. Scan all ArcFunction blocks for Construct instructions. The key structural difference: update_field_summaries runs inside the fixpoint loop and reads variable ranges from the fixpoint state; float collection is a single-pass post-fixpoint scan that only needs to check literal values.

    Concrete approach:

    pub fn collect_float_field_summaries(
        plan: &ReprPlan,
        pool: &Pool,
        arc_functions: &[ArcFunction],
    ) -> FloatFieldSummaryTable {
        let mut table = FloatFieldSummaryTable::new();
        for func in arc_functions {
            for block in &func.blocks {
                for instr in &block.body {
                    // Only Construct for Struct/Tuple/EnumVariant (same filter as field_summary.rs:188-194)
                    let ArcInstr::Construct { ty, ctor, args, .. } = instr else { continue };
                    match ctor {
                        CtorKind::Struct(_) | CtorKind::Tuple | CtorKind::EnumVariant { .. } => {}
                        _ => continue,
                    }
                    // For each argument, check if it's a float field
                    for (i, arg) in args.iter().enumerate() {
                        let arg_ty = func.var_types.get(arg.index()).copied();
                        let is_float = arg_ty.is_some_and(|t| pool.tag(pool.resolve_fully(t)) == Tag::Float);
                        if !is_float { continue; }
                        // Check if the defining instruction is a Literal(Float)
                        let range = find_literal_float_value(func, *arg)
                            .map(|bits| FloatRange::Bottom.observe(f64::from_bits(bits)))
                            .unwrap_or(FloatRange::Top); // variable/arithmetic → conservative
                        table.observe(*ty, i as u32, range);
                    }
                }
            }
        }
        table
    }

    Helper find_literal_float_value(): Walk the function’s blocks to find where arg is defined. If it’s a Let { value: ArcValue::Literal(LitValue::Float(bits)), .. }, return Some(bits). Otherwise return None (conservative — variable/arithmetic source). This is a simple linear scan since ARC IR is SSA (each variable defined exactly once). For Phase A simplicity, a per-function FxHashMap<ArcVarId, u64> pre-populated by scanning all Let instructions for LitValue::Float values works well.

    Non-float fields are ignored — the is_float check filters them out.

  • Wire collect_float_field_summaries() into apply_float_narrowing(): The stub apply_float_narrowing(_plan, _pool) in compiler/ori_repr/src/lib.rs:504 needs to be filled in. Following §04’s pattern (apply_integer_narrowing at lib.rs:490):

    1. Update signature: Change fn apply_float_narrowing(_plan: &mut ReprPlan, _pool: &Pool) {} to fn apply_float_narrowing(plan: &mut ReprPlan, pool: &Pool, arc_functions: &[ArcFunction]). Update the call site at lib.rs:226 to pass arc_functions (it is already in scope — see analyze_ranges call at line 224).
    2. Gate on plan.narrowing_policy() != Disabled (early return)
    3. Gate on plan.is_integer_narrowing_safe_for_codegen() — this method name is misleading for float narrowing but the underlying check (whether has_analysis_only_functions prevents codegen-visible narrowing, see plan.rs:348) applies equally to float narrowing. Concrete action: rename the method to is_narrowing_safe_for_codegen() in plan.rs:348, update the set_has_analysis_only_functions() doc at plan.rs:357, and update all 3 call sites (lib.rs:496, lib.rs new float site, range/signatures/mod.rs:213). This rename is a hygiene improvement — the method was always about narrowing-in-general, not just integer narrowing.
    4. Call collect_float_field_summaries(plan, pool, arc_functions) to populate the float field summary table
    5. Call narrow_float_fields(plan, pool, &float_table) (§05.3) passing the summary table

BLOAT note: compiler/ori_repr/src/lib.rs is currently 533 lines (exceeds the 500-line limit). Adding the float narrowing body will push it further over. Before implementing §05.2, extract the apply_integer_narrowing() and apply_float_narrowing() function bodies from lib.rs into narrowing/int.rs and narrowing/float.rs respectively, leaving only thin forwarding calls in lib.rs. This reduces lib.rs to its proper role as an index/dispatcher.


05.3 Float Field Narrowing

TDD order: Write unit tests for narrow_float_fields() BEFORE implementing. The test matrix covers: f32-exact narrowed, non-f32-exact preserved, #repr("c")/#repr("packed")/#repr("transparent")/#repr("aligned", N) skipped, public types skipped, NarrowingPolicy::Disabled skipped, tuples deferred, combined int+float narrowing. Verify all tests fail with the stub implementation, then implement, then verify they pass unchanged. Run in both debug and release.

File(s): compiler/ori_repr/src/narrowing/float.rs

Float narrowing follows the same pattern as narrow_struct_fields() in narrowing/int.rs:

  • Implement narrow_float_fields():

    /// Narrow float fields in struct types from f64 to f32 when all observed
    /// values are f32-exact. Follows the same conservatism rules as integer
    /// narrowing (§04):
    ///
    /// - `#repr("c")` / `#repr("packed")` / `#repr("transparent")` → skip
    /// - Public types → skip (ABI contract)
    /// - `NarrowingPolicy::Disabled` → skip
    /// - Only fields with `MachineRepr::Float { width: F64 }` are candidates
    pub fn narrow_float_fields(
        plan: &mut ReprPlan,
        pool: &Pool,
        float_table: &FloatFieldSummaryTable,
    ) { ... }

    The implementation mirrors narrowing/int.rs::narrow_struct_fields() (line 32):

    1. Collect type indices with Struct representations using plan.decision_indices() + filter on MachineRepr::Struct(_) (same pattern as int.rs:42-52)
    2. Skip types with has_fixed_layout_attr(). Concrete action: has_fixed_layout_attr() is currently a private fn in narrowing/int.rs:201. Change its visibility to pub(crate) and either (a) move it to narrowing/mod.rs for shared use, or (b) import it from super::int::has_fixed_layout_attr in float.rs. Option (a) is preferred since the function is a shared narrowing concern. Also move CandidateKind (currently private in int.rs:231-234) to narrowing/mod.rs with pub(crate) visibility for reuse.
    3. Skip plan.is_public_type(idx) types (method at plan.rs:285)
    4. For each Float { width: F64 } field in the struct, query float_table.get(idx, field.original_index) for the field’s FloatRange
    5. If FloatRange::F32Exact → rewrite field.repr to MachineRepr::Float { width: FloatWidth::F32 } (import FloatWidth from crate::repr)
    6. Record decision with DecisionSource::FloatNarrowing (already exists at decision.rs:35) and DecisionReason::Custom(summary_string). Build the summary string with a float_field_range_summary_string() helper (follow field_range_summary_string() at int.rs:214).
    7. Also store under pool.resolve_fully(idx) if different (same pattern as int.rs:101-112)
  • Float field summary storage — use local table (recommended for Phase A): The FloatFieldSummaryTable is built by collect_float_field_summaries() and consumed by narrow_float_fields() within the same apply_float_narrowing() call. Unlike integer ranges (which need persistent storage for the fixpoint loop and cross-function analysis), float field summaries have no fixpoint dependency — they are computed once from literal values. Store the table as a local variable in apply_float_narrowing() and pass it to narrow_float_fields() by reference. Do NOT add a new field to ReprPlan unless a future section needs cross-pass access to float precision data.

    If persistent storage is needed later (e.g., for audit trail dumping), add float_field_summaries: FxHashMap<(Idx, u32), FloatRange> to ReprPlan following the field_range_summaries pattern at plan.rs:101. But this is not needed for Phase A.

  • Tuple narrowing: Same deferral as §04 Phase A — tuples are skipped. The narrow_float_fields() function must check for CandidateKind::Tuple and skip with tracing, identical to §04’s tuple handling in narrow_struct_fields(). Float tuple fields are not narrowed until collection/element integration (future Phase C equivalent).

  • NarrowingPolicy handling: narrow_float_fields() must check plan.narrowing_policy() and return early if Disabled. When Conservative, apply the same rules as integer narrowing (only provably-safe narrowing). When Aggressive, narrow all fields where FloatRange::F32Exact is observed.

  • Combined int+float narrowed struct handling: When §04 has already narrowed some int fields and §05 narrows additional float fields in the same struct, the resulting MachineRepr::Struct contains a mix of narrowed types. §05 must read the struct repr that §04 may have already modified (via plan.get_repr(idx)), apply float narrowing to any remaining Float { F64 } fields, and write back the combined result. This is natural if §05 runs after §04 (which it does — lib.rs:225-226), but the implementation must NOT assume the struct is in its canonical state — it must read the current (possibly already-narrowed-by-§04) repr.


05.4 LLVM Codegen: fpext/fptrunc at Boundaries

File(s): compiler/ori_llvm/src/codegen/ (multiple files — ArcIrEmitter, layout_resolver.rs)

TDD order: Write the AOT tests listed in §05.5 BEFORE implementing the codegen changes. The IR pin tests (test_float_narrowed_struct_ir_pin_type_layout, test_float_narrowed_struct_ir_pin_fptrunc_on_construction, etc.) should fail because no fptrunc/fpext instructions are emitted yet. After implementation, they must pass unchanged. Run AOT tests in both debug and release: timeout 150 cargo test -p ori_llvm -- float and timeout 150 cargo test --release -p ori_llvm -- float.

What already works: TypeLayoutResolver::try_repr_to_llvm_type() in layout_resolver.rs:157-159 already handles MachineRepr::Float { F32 }context.f32_type(). When §05.3 writes Float { F32 } into a struct field’s FieldRepr, the struct’s LLVM type will automatically use float (f32) for that field. No changes needed to try_repr_to_llvm_type() itself.

What’s missing: The ARC IR operates on f64 values (canonical float). When codegen stores an f64 value into an f32 struct field, it must insert fptrunc double to float. When loading from an f32 field back to an f64 computation, it must insert fpext float to double. These conversions happen at the Construct (store) and Project (load) instruction boundaries.

Implementation order (items listed in dependency order — implement top-to-bottom):

  • (prerequisite) BLOAT extraction deferred — emitter_utils.rs stays cohesive with float branches added inline (same pattern as integer narrowing). Extraction can happen in a dedicated cleanup pass.

  • Add float_trunc(), float_ext(), and fpext_to_f64_if_narrower() methods to IrBuilder: WHERE: compiler/ori_llvm/src/codegen/ir_builder/conversions.rs (currently 181 lines — well within 500-line limit). Verified missing: IrBuilder has trunc() (line 19), sext() (line 56), si_to_fp() (line 88), fp_to_si() (line 104) but NO float-to-float truncation or extension methods. Add two methods following the pattern of trunc() (line 19-32) and sext() (line 56-69):

    /// Build float truncation (e.g., f64 → f32).
    pub fn float_trunc(&mut self, val: ValueId, ty: LLVMTypeId, name: &str) -> ValueId {
        let v = self.arena.get_value(val);
        let target = self.arena.get_type(ty).into_float_type();
        if !v.is_float_value() {
            tracing::error!(val_type = ?v.get_type(), "float_trunc on non-float operand");
            self.record_codegen_error();
            return self.const_f64(0.0);
        }
        let result = self.builder
            .build_float_trunc(v.into_float_value(), target, name)
            .expect("float_trunc");
        self.arena.push_value(result.into())
    }
    
    /// Build float extension (e.g., f32 → f64).
    pub fn float_ext(&mut self, val: ValueId, ty: LLVMTypeId, name: &str) -> ValueId {
        let v = self.arena.get_value(val);
        let target = self.arena.get_type(ty).into_float_type();
        if !v.is_float_value() {
            tracing::error!(val_type = ?v.get_type(), "float_ext on non-float operand");
            self.record_codegen_error();
            return self.const_f64(0.0);
        }
        let result = self.builder
            .build_float_ext(v.into_float_value(), target, name)
            .expect("float_ext");
        self.arena.push_value(result.into())
    }

    Note: inkwell::builder::Builder provides build_float_trunc() and build_float_ext() — verify these method names match the inkwell 0.5+ API (the crate uses LLVM 21 / inkwell llvm21-1).

  • Extend trunc_for_narrowed_struct() for float fields: The existing trunc_for_narrowed_struct() in emitter_utils.rs:501 only handles Tag::Int fields (checks pool.tag() == Tag::Int and uses integer trunc). §05 must extend this to ALSO handle Tag::Float fields. When the pool field type is Tag::Float (canonical f64) but the struct’s LLVM field type is float (f32), insert fptrunc double to float.

    Implementation approach: In trunc_for_narrowed_struct(), add a second branch after the existing int check:

    // Existing: integer truncation for narrowed int fields
    let is_int_field = ...;
    if is_int_field { /* existing trunc logic */ }
    
    // NEW: float truncation for narrowed float fields
    let is_float_field = field_pool_types
        .get(i)
        .is_some_and(|&idx| self.pool.tag(self.pool.resolve_fully(idx)) == Tag::Float);
    if is_float_field {
        if let Some(BasicTypeEnum::FloatType(field_float)) = st.get_field_type_at_index(i as u32) {
            // f32 field but f64 value → fptrunc
            // Compare LLVM types: field is f32 but value is f64
            let v = self.builder.arena.get_value(val);
            if v.is_float_value() {
                let val_float_ty = v.into_float_value().get_type();
                // inkwell FloatType does not have get_bit_size(); compare types directly
                if val_float_ty != field_float {
                    let field_ty_id = self.builder.register_type(field_float.into());
                    return self.builder.float_trunc(val, field_ty_id, &format!("fptrunc.{i}"));
                }
            }
        }
    }

    Prerequisite: IrBuilder::float_trunc() must be added first (see checklist item above). The trunc_for_narrowed_struct() closure currently uses .map(|(i, &val)| { ... }) — the new float branch must be added inside this closure, after the existing int-field check at emitter_utils.rs:535-559. Implementation note: the existing code structure is a .map() closure returning val (unchanged) or a truncated value — the float branch follows the same return pattern.

  • Extend sext_narrowed_field() for float fields: The existing sext_narrowed_field() in emitter_utils.rs:573 only handles Tag::Int fields (checks pool.tag() == Tag::Int and uses integer sext). §05 must extend this to ALSO handle Tag::Float fields. When the ARC IR destination type is Tag::Float (expects f64) but the extracted value is f32, insert fpext float to double.

    Implementation approach: In sext_narrowed_field() at emitter_utils.rs:573, add a float branch after the existing int check (line 583). The existing function structure is: check Tag::Int → check is_int_value() → check bits < 64sext. The float branch mirrors this:

    // Existing: integer sign-extension for narrowed int fields (line 583)
    if self.pool.tag(resolved) == Tag::Int { /* existing sext logic */ }
    
    // NEW: float extension for narrowed float fields
    if self.pool.tag(resolved) == Tag::Float {
        let v = self.builder.arena.get_value(extracted);
        if v.is_float_value() {
            let val_float_ty = v.into_float_value().get_type();
            let canonical_f64 = self.builder.scx.type_f64();
            // If extracted value is f32 but destination expects f64, extend
            if val_float_ty != canonical_f64 {
                let f64_ty_id = self.builder.register_type(canonical_f64.into());
                return self.builder.float_ext(extracted, f64_ty_id, &format!("fpext.{field_index}"));
            }
        }
    }

    Note: The existing int path accesses self.builder.scx (not self.scx) for type construction — see emitter_utils.rs:594-595 pattern. The self.builder.register_type()self.builder.scx.type_f64() chain follows the same pattern as the existing self.builder.register_type(self.builder.scx.type_i64().into()) at line 595. Prerequisite: IrBuilder::float_ext() must be added first.

  • Extend try_lower_narrowed_aggregate() for float-narrowed fields: try_lower_narrowed_aggregate() in layout_resolver.rs:318 has two relevant guards:

    Guard 1 — has_narrowed (line 335-346): Currently only checks for Int { width != I64 }. A struct with ONLY float fields narrowed to F32 would have has_narrowed = false and fall through to the TypeInfoStore named-struct path (incorrect — the struct IS narrowed). Fix: extend the has_narrowed check to also detect Float { width: F32 }:

    let has_narrowed = fields.iter().any(|f| {
        matches!(
            f.repr,
            MachineRepr::Int { width, .. } if width != ori_repr::IntWidth::I64
        ) || matches!(
            f.repr,
            MachineRepr::Float { width } if width != ori_repr::FloatWidth::F64
        )
    });

    Guard 2 — all_scalar_int (line 354-366): Despite its misleading name, this guard already accepts MachineRepr::Float { .. } in the match (line 358). No change is needed to the match itself. However, rename the variable from all_scalar_int to all_scalar_fields to reflect that it covers both int and float fields (hygiene improvement).

  • Function boundaries remain canonical f64: Function parameters and return values always use f64 (canonical float). Float narrowing only affects struct field storage. No ABI changes. This is identical to §04’s rule that function boundaries remain canonical i64.

Risk: The ArcIrEmitter must have access to the ReprPlan to know which fields are narrowed. The TypeLayoutResolver already holds repr_plan: Option<&ReprPlan> (line 48), and the emitter accesses it via self.resolver.repr_plan() (line 76). This access path is already live from §04’s integer narrowing.

BLOAT note: emitter_utils.rs is currently 600 lines (exceeds the 500-line limit). Adding float branches to trunc_for_narrowed_struct() and sext_narrowed_field() will push it further over. Before implementing §05.4, extract trunc_for_narrowed_struct() (line 501-563) and sext_narrowed_field() (line 573-599) into a new submodule arc_emitter/narrowing_codegen.rs, leaving forwarding methods in emitter_utils.rs. This is a prerequisite, not optional cleanup.


05.5 Completion Checklist

Test matrix for §05 (write failing tests FIRST, verify they fail, then implement):

Input patternExpected narrowingSemantic pin
Struct field r: float where all stored literals are 0.0Float { F32 } in MachineReprYes — field_repr.repr is F32
Struct field scale: float storing constant 0.5Float { F32 } storageYes — is_f32_exact(0.5) == true
Struct field x: float storing constant 1e300 (non-f32-exact)Float { F64 } — no narrowingYes — is_f32_exact(1e300) == false
Float arithmetic a + b → result stored in struct fieldFloat { F64 } — conservative (no narrowing)Yes — arithmetic result stays f64
struct Color { r: float, g: float, b: float } with all values from 0.0, 0.5, 1.0 literalsFloat { F32 } fieldsYes — all literal values are f32-exact
float field receiving variable (non-literal)Float { F64 } — conservativeYes — variable source is unknown
is_f32_exact(0.1)false (0.1 is not f32-exact)Yes — precision pin
is_f32_exact(0.5)true (exact in f32)Yes
is_f32_exact(-0.0)true (negative zero is f32-exact)Yes
is_f32_exact(f64::INFINITY)true (infinity is f32-exact)Yes
is_f32_exact(f64::NAN)false (NaN payload may not survive)Yes
Struct with #repr("c") and f32-exact fieldsFloat { F64 } — not narrowed (ABI)Yes — negative pin
Public struct with f32-exact fieldsFloat { F64 } — not narrowed (ABI)Yes — negative pin
Private struct with mix of f32-exact and non-f32-exact fieldsOnly f32-exact fields narrowedYes — per-field decision
Struct with f64::MIN_POSITIVE (smallest positive f64 normal, 2.2250738585072014e-308)Float { F64 } — not f32-exact (below f32 smallest subnormal, flushes to zero in f32)Yes — subnormal edge
Struct with #repr("aligned", 16) and f32-exact fieldsFloat { F32 } — narrowed (aligned only sets whole-struct alignment, not field layout)Yes — aligned does NOT prevent narrowing
Struct with int [0,255] AND float 0.5 fields (§04+§05 combined){ i8, f32 } — both narrowedYes — combined §04+§05 semantic pin
Tuple (float, float) with f32-exact valuesFloat { F64 } — not narrowed (Phase A defers tuples)Yes — tuple deferral negative pin
NarrowingPolicy::Disabled with f32-exact struct fieldsFloat { F64 } — not narrowedYes — policy negative pin
Struct field storing f32::MAX as f64 (boundary f32 value)Float { F32 } — f32-exactYes — boundary positive pin
Struct field storing (f32::MAX as f64) * 2.0 (just beyond f32 range)Float { F64 } — overflows to f32::INFINITY, not losslessYes — boundary negative pin

Unit tests (Rust, in compiler/ori_repr/src/narrowing/float/tests.rs):

Note: narrowing/tests.rs already exists and contains integer narrowing tests (§04). Float tests MUST go in narrowing/float/tests.rs (sibling test file convention — float.rsfloat/tests.rs). This means converting float.rs to float/mod.rs + float/tests.rs directory structure. Add #[cfg(test)] mod tests; at the bottom of float/mod.rs.

  • is_f32_exact() correctly identifies f32-representable f64 values:
    • is_f32_exact(0.0), is_f32_exact(1.0), is_f32_exact(0.5), is_f32_exact(100.0)true
    • is_f32_exact(1.0 / 3.0), is_f32_exact(1e300), is_f32_exact(f64::NAN)false
    • is_f32_exact(-0.0)true (negative zero is f32-exact)
    • is_f32_exact(f64::INFINITY)true, is_f32_exact(f64::NEG_INFINITY)true
    • is_f32_exact(f32::MAX as f64)true, is_f32_exact(f32::MIN as f64)true
    • is_f32_exact(f64::MAX)false (exceeds f32 range)
    • is_f32_exact(16777216.0)true (2^24, last exact f32 integer)
    • is_f32_exact(16777217.0)false (2^24 + 1, not f32-exact)
    • is_f32_exact(f64::MIN_POSITIVE)false (f64 subnormal boundary — too small for f32 normal range)
    • is_f32_exact(f32::MIN_POSITIVE as f64)true (f32’s smallest positive normal)
    • is_f32_exact(1e-45_f64) → verify empirically: 1e-45_f64 is approximately 1.401298e-45 which is f32::MIN_POSITIVE * 2^-23 (the smallest f32 subnormal). The roundtrip 1e-45_f64 as f32 as f64 may produce a slightly different value due to f64→f32 rounding. Test the actual roundtrip result. If the Rust expression (1e-45_f64 as f32) as f64 == 1e-45_f64 is false, the expected result is false.
  • FloatRange lattice operations: Bottom.join(F32Exact) == F32Exact, F32Exact.join(Top) == Top, Bottom.join(Top) == Top, F32Exact.join(F32Exact) == F32Exact, Bottom.join(Bottom) == Bottom, Top.join(Top) == Top
  • FloatRange::observe(): Bottom.observe(0.5) == F32Exact, F32Exact.observe(0.1) == Top, Top.observe(0.5) == Top
  • FloatRange::observe_arithmetic(): Bottom.observe_arithmetic() == Top, F32Exact.observe_arithmetic() == Top (conservative — any arithmetic blocks narrowing)
  • narrow_float_fields() narrows f32-exact struct fields
  • narrow_float_fields() preserves f64 for non-f32-exact struct fields
  • narrow_float_fields() skips #repr("c") types
  • narrow_float_fields() skips #repr("packed") types
  • narrow_float_fields() skips #repr("transparent") types
  • narrow_float_fields() does NOT skip #repr("aligned", N) types (aligned only sets whole-struct alignment)
  • narrow_float_fields() skips public types
  • narrow_float_fields() skips when NarrowingPolicy::Disabled (caller gates, verified)
  • narrow_float_fields() skips tuples (Phase A — same as §04)
  • Mixed struct: some int fields narrowed by §04, some float fields narrowed by §05, others kept canonical
  • Combined int+float narrowing: struct Pixel { r: int, x: float } where r in [0, 255] and x is always 0.5 → { i8, f32 } (both narrowed)
  • FloatFieldSummaryTable correctly joins multiple observations: two f32-exact stores → F32Exact; one f32-exact + one non-f32-exact → Top
  • collect_float_field_summaries() ignores non-float fields (int, bool, str fields produce no float entries — type filter in collection loop)
  • collect_float_field_summaries() handles struct with all float fields, all f32-exact → all F32Exact (table observe logic verified)
  • collect_float_field_summaries() handles struct with no Construct sites → all fields Bottom (no evidence, no narrowing)

AOT tests (Rust, in compiler/ori_llvm/tests/aot/narrowing.rs — extend §04’s test file):

  • test_float_narrowed_struct_roundtrip: Struct with f32-exact fields (0.0, 0.5, 1.0), store and load, verify bit-identical results (2026-03-29)
  • test_float_narrowed_struct_ir_pin_type_layout: Verify { float, float, float } (not { double, double, double }) in LLVM IR for Color struct with f32-exact fields (2026-03-29)
  • test_float_narrowed_struct_ir_pin_fptrunc_on_construction: Verify fptrunc double to float in LLVM IR at struct construction (2026-03-29)
  • test_float_narrowed_struct_ir_pin_fpext_on_field_load: Verify fpext float to double in LLVM IR at field extraction (2026-03-29)
  • test_float_non_narrowed_struct_ir_pin_wide_value: Negative pin — struct with 1e300 field shows NO fptrunc (2026-03-29)
  • test_float_arithmetic_not_narrowed: Float arithmetic result stored in struct field stays f64 (2026-03-29)
  • test_float_variable_not_narrowed: Non-literal float variable stored in struct field stays f64 (2026-03-29)
  • test_mixed_int_float_narrowed_struct: Struct with int [0,255] and float 0.5 → { i8, float } in LLVM IR — verifies combined §04+§05 narrowing (2026-03-29)
  • test_float_repr_c_not_narrowed: Negative pin — #repr("c") struct with f32-exact fields shows double (not float) in LLVM IR (2026-03-29)
  • Release parity verified: all 15 float AOT tests pass in both debug and release — filter cargo test -p ori_llvm -- float selects 14 test_float_* + 1 test_mixed_int_float_* in the narrowing module (2026-03-29). FastISel vs full ISel parity confirmed.
  • test_float_narrowed_derive_printable: Derived Printable on narrowed float struct — TPR-05-001 regression guard (2026-03-29)
  • test_float_narrowed_derive_debug: Derived Debug on narrowed float struct (2026-03-29)
  • test_float_narrowed_derive_hash: Derived Hashable on narrowed float struct (2026-03-29)
  • test_float_narrowed_derive_eq: Derived Eq on narrowed float struct (2026-03-29)
  • test_float_narrowed_derive_comparable: Derived Comparable on narrowed float struct (2026-03-29)
  • test_float_narrowed_derive_ir_pin_fpext_in_printable: IR pin — fpext in derive Printable codegen (2026-03-29)

All AOT tests must pass in BOTH debug and release builds. FastISel (debug) and the full instruction selector (release) handle fptrunc/fpext differently; a test that passes in debug but fails in release is a real bug.

Spec tests (Ori, in tests/spec/repr/float_narrowing/):

These are end-to-end behavioral tests. The Ori code itself does not observe the narrowing (it is a hidden optimization), so these tests verify semantic transparency — the program produces the same result whether or not narrowing occurs.

  • basic_roundtrip.ori: Struct storing 0.5 produces bit-identical results to canonical f64 path — assert_eq(p.x, 0.5) (2026-03-29)
  • arithmetic_transparent.ori: Arithmetic on float values is not affected by field narrowing — store 0.5 in struct, load, add 1.0, verify result is 1.5 (2026-03-29)
  • mixed_fields.ori: Struct with mix of f32-exact (0.5) and non-f32-exact (0.1) fields — both fields produce correct values (2026-03-29)
  • multiple_stores.ori: Same struct field written at multiple call sites with different f32-exact values (0.0, 1.0, 255.0) — all roundtrip correctly (2026-03-29)
  • combined_int_float.ori: Struct with both int and float fields — both types produce correct values after narrowing (2026-03-29)
  • negative_zero.ori: Struct storing -0.0 — verify -0.0 is preserved (not collapsed to 0.0) (2026-03-29)
  • boundary_values.ori: Struct storing f32 boundary values — powers of 2, precision edge (2^24-1), small fractions roundtrip correctly (2026-03-29)
  • comparison_after_load.ori: Float field loaded from narrowed struct used in comparison — <, >, ==, !=, range checks (2026-03-29)

Integration:

  • Write failing test matrix BEFORE implementation — N/A: §05.1-§05.4 already complete; spec tests written post-implementation to verify semantic transparency (2026-03-29)
  • Interpreter and LLVM produce identical results — verified via two separate paths: (1) Interpreter: 36/36 spec tests pass (ori test tests/spec/repr/float_narrowing/). (2) LLVM: 20 AOT tests pass in debug+release (cargo test -p ori_llvm --test aot -- narrowing), covering all 8 spec scenarios with dedicated counterparts (including test_float_narrowed_mixed_exact_non_exact for the mixed exact/non-exact scenario). The spec-test-to-LLVM path (ori test --backend=llvm) is blocked by systemic assert_eq monomorphization (affects 3992 files) — dual-exec-verify.sh reports 0 verified / 36 LLVM compile-fail. LLVM coverage is through the AOT path (2026-03-29)
  • Bit-identical results: LLVM AOT path produces identical results to interpreter for all 8 spec test scenarios — each scenario has a dedicated @main-based AOT test that checks the same computation. dual-exec-verify.sh cannot verify these specific files (LLVM compile-fail); cross-backend parity is established by AOT test equivalence instead (2026-03-29)
  • ./test-all.sh green in debug (14,544 passed, 0 failed — 2026-03-29, post-spec-tests)
  • ./clippy-all.sh green (2026-03-29)
  • ./diagnostics/valgrind-aot.sh clean — tests/valgrind/float_narrowing.ori passes Valgrind with zero memory errors (exercises f32 storage, negative zero, boundary values, multiple stores, comparison); spec tests can’t AOT-compile due to assert_eq mono gap (2026-03-29)
  • /tpr-review passed — 6 iterations, 10 findings (TPR-05-009 through TPR-05-018), all resolved. Zero code correctness bugs; all findings were evidence/documentation accuracy. Accepted clean on 2026-03-29.
  • /impl-hygiene-review passed — implementation hygiene review clean (phase boundaries, SSOT, algorithmic DRY, naming). MUST run AFTER /tpr-review is clean. (2026-03-31)
  • /improve-tooling retrospective — N/A: section was closed before the retrospective gate was added on 2026-04-07. Any future work touching this code path should run the retrospective via /improve-tooling Retrospective Mode.
  • Memory safety verified: (1) Valgrind clean on tests/valgrind/float_narrowing.ori (dedicated @main program exercising all scenarios); (2) 20 AOT tests pass without memory errors in debug+release; (3) ORI_CHECK_LEAKS=1 is a runtime-binary feature — spec test files (interpreter-only, @test functions without @main) do not exercise this path (2026-03-29)

Cleanup (after §05 is complete):

  • Remove any §05 / TPR-05-* code annotations from production code — cleaned all plan section refs (§02–§11, TPR-05-, CROSS-04-) from 15+ files; spec refs (§05-variables.md) preserved (2026-03-29)
  • Verify narrowing/mod.rs module doc is updated to include float module — already includes float in architecture list and phase descriptions (2026-03-29)
  • Verify lib.rs re-exports are clean — 60 lines, pure index: //! docs + mod + pub use only (2026-03-29)

Exit Criteria: A program storing constant 0.5 in a private struct field uses Float { F32 } in the ReprPlan and float (f32) in LLVM IR instead of double (f64), verified by inspecting generated IR. A struct with both int (narrowed by §04) and float (narrowed by §05) fields shows the combined narrowed LLVM type. All floating-point spec tests continue to pass with bit-identical results. The fptrunc double to float at store and fpext float to double at load are visible in ORI_DUMP_AFTER_LLVM=1 output. §07’s find_niches() returns empty for MachineRepr::Float { F32 } fields (verified by §07’s test matrix, documented here as handoff contract).


05.R Third Party Review Findings

  • [TPR-05-019][low] compiler/oric/src/commands/codegen_pipeline.rs:229The §05 extraction still leaves codegen_pipeline.rs over the repository’s 500-line source-file limit. Resolved: Fixed on 2026-03-29. Extracted lower_impl_methods_for_analysis() (impl-method ARC lowering for repr analysis) plus make_qualified_name() and lower_default_trait_methods() helpers into repr_setup.rs. Result: codegen_pipeline.rs = 497 lines, repr_setup.rs = 400 lines — both under 500. Updated module doc. All 14,549 tests pass, clippy clean.

  • [TPR-05-001][major] compiler/ori_llvm/src/codegen/derive_codegen/string_helpers.rs:116Derived Printable/Debug on narrowed float fields passes float to the ori_str_from_float(double) runtime ABI, producing invalid LLVM IR. Resolved: Fixed on 2026-03-29. Added fpext_to_f64_if_narrower() call in emit_field_to_string() Float arm (mirroring the integer sext_to_i64_if_narrower pattern). Added test_float_narrowed_derive_printable, test_float_narrowed_derive_debug, and test_float_narrowed_derive_ir_pin_fpext_in_printable as regression guards. All 14,496 tests pass in both debug and release.

  • [TPR-05-002][major] plans/repr-opt/section-05-float-narrowing.md:309§05.4 marked complete without required float AOT regression suite. Resolved: Fixed on 2026-03-29. Added 15 float AOT tests covering: roundtrip, IR type layout pin, fptrunc/fpext IR pins, negative pins (wide values, arithmetic, variables, #repr("c")), mixed int+float narrowing, and 5 derive trait tests (Printable, Debug, Hash, Eq, Comparable). All pass in debug and release builds.

  • [TPR-05-003][medium] plans/repr-opt/section-05-float-narrowing.md:517The §05.5 checklist and resolved TPR note overstate the landed release regression coverage. Resolved: Fixed on 2026-03-29. Corrected test count from 16 to 15, replaced nonexistent test_float_narrowed_struct_release_roundtrip entry with actual release verification evidence (all 15 tests pass in both debug and release builds).

  • [TPR-05-004][medium] compiler/ori_llvm/src/codegen/arc_emitter/emitter_utils.rs:501§05.4 added more narrowing code to an over-limit emitter file instead of performing the prerequisite extraction. Resolved: Fixed on 2026-03-29. Extracted 14 narrowing methods into arc_emitter/narrowing_codegen.rs (469 lines). emitter_utils.rs reduced from 652 to 205 lines.

  • [TPR-05-005][medium] compiler/ori_repr/src/lib.rs:490The float narrowing work touched lib.rs without performing the planned extraction, leaving lib.rs as a 545-line implementation file. Resolved: Fixed on 2026-03-29. Extracted all function bodies into pipeline.rs (497 lines). lib.rs reduced from 545 to 60 lines (pure index: //! docs, mod declarations, pub use re-exports only).

  • [TPR-05-006][medium] plans/repr-opt/section-05-float-narrowing.md:517The recorded release-parity command still overstates float narrowing coverage. Resolved: Fixed on 2026-03-29. Replaced float_narrowed filter with float which matches all 15 narrowing tests (14 test_float_* + 1 test_mixed_int_float_*). Verified: cargo test --release -p ori_llvm -- float passes all 15 narrowing + 39 other float tests (54 total AOT).

  • [TPR-05-007][medium] plans/repr-opt/00-overview.md:338The plan summary files still advertise §05 as “Not Started.” Resolved: Fixed on 2026-03-29. Updated 00-overview.md and index.md to reflect §05 as “In Progress (§05.1–§05.4 complete)”.

  • [TPR-05-008][medium] compiler/ori_repr/src/narrowing/float/mod.rs:317The landed unit suite does not exercise collect_float_field_summaries() or build_float_literal_map(). Resolved: Fixed on 2026-03-29. Added 12 unit tests (test_collect_*) covering: empty input, f32-exact literal, non-f32-exact literal, variable arg (Top), mixed float/non-float fields, multiple construct sites (join), non-struct ctor kinds (skip), enum variant ctor, tuple ctor, cross-function join, and cross-block join.

  • [TPR-05-009][major] plans/repr-opt/section-05-float-narrowing.md:543The §05.5 checklist still marks LLVM/spec parity and leak verification complete even though the new float narrowing spec tests currently provide zero LLVM coverage. Resolved: Fixed on 2026-03-29. Added 4 AOT parity tests (test_float_narrowed_negative_zero, test_float_narrowed_boundary_values, test_float_narrowed_multiple_stores, test_float_narrowed_comparison_after_load) to close the LLVM coverage gap. All 19 float narrowing AOT tests pass in both debug and release. Reworded checklist items to accurately state that LLVM parity is verified through the AOT test path (19 tests including test_mixed_int_float_narrowed_struct), not the spec test path (which is blocked by the systemic assert_eq monomorphization limitation).

  • [TPR-05-010][medium] plans/repr-opt/section-05-float-narrowing.md:543The refreshed §05.5 parity evidence still overstates LLVM coverage for the spec matrix. Resolved: Fixed on 2026-03-29. Updated checklist to cite 19 total float narrowing AOT tests (not 18), explicitly listing test_mixed_int_float_narrowed_struct alongside the 18 test_float_* tests. The corrected text enumerates all 5 filter commands needed to run the full 19-test suite, making the evidence verifiable and accurate.

  • [TPR-05-011][medium] plans/repr-opt/section-05-float-narrowing.md:547The §05.5 Valgrind checklist item is still marked complete without any Valgrind-backed float-narrowing coverage. Resolved: Fixed on 2026-03-29. Created tests/valgrind/float_narrowing.ori — a @main program exercising f32 storage, negative zero, boundary values, multiple stores, and comparison. ./diagnostics/valgrind-aot.sh tests/valgrind/float_narrowing.ori passes with zero memory errors. Updated checklist to cite actual Valgrind evidence instead of cargo test.

  • [TPR-05-012][medium] plans/repr-opt/section-05-float-narrowing.md:543The checklist still marks interpreter/LLVM parity complete even though the cited dual-exec path verifies zero float-narrowing spec tests on LLVM. Resolved: Fixed on 2026-03-29. Rewrote both parity checklist items to accurately state: (1) interpreter parity comes from cargo st, (2) LLVM parity comes from 19 dedicated AOT tests, (3) dual-exec-verify.sh cannot verify these files (0 verified / 36 LLVM compile-fail), and (4) cross-backend parity is established through AOT test equivalence, not dual-exec. The items no longer cite dual-exec as verification evidence.

  • [TPR-05-013][medium] plans/repr-opt/section-05-float-narrowing.md:547The ORI_CHECK_LEAKS=1 checklist evidence cites the 8 spec files, but those artifacts are not executable leak-check targets in their current form. Resolved: Fixed on 2026-03-29. Replaced the ORI_CHECK_LEAKS claim with accurate memory safety evidence: (1) Valgrind clean on tests/valgrind/float_narrowing.ori, (2) 19 AOT tests pass without memory errors, (3) explicitly notes that ORI_CHECK_LEAKS is a runtime-binary feature that does not apply to interpreter-only @test files.

  • [TPR-05-014][medium] plans/repr-opt/section-05-float-narrowing.md:543The recorded interpreter verification command is not the scoped 36-test run the checklist claims. Resolved: Fixed on 2026-03-29. Changed checklist to cite ori test tests/spec/repr/float_narrowing/ (the direct binary invocation that produces scoped 36-test results) instead of cargo st (which appends tests/ and runs the full suite).

  • [TPR-05-015][medium] plans/repr-opt/section-05-float-narrowing.md:543The checklist still overstates LLVM parity coverage for the spec matrix: the mixed exact/non-exact float-field scenario has no dedicated AOT counterpart. Resolved: Fixed on 2026-03-29. Added test_float_narrowed_mixed_exact_non_exact in narrowing.rs — struct with 0.5 (f32-exact, narrowable) and 0.1 (non-f32-exact, stays f64). Updated checklist to cite 20 AOT tests. All 8 spec scenarios now have dedicated AOT counterparts.

  • [TPR-05-016][medium] diagnostics/dual-exec-verify.sh:483dual-exec-verify.sh reports success even when it verifies nothing, which makes zero-coverage runs look green. Resolved: Filed as BUG-07-002 in plans/bug-tracker/section-07-tooling-cli.md on 2026-03-29. This is a tooling bug outside the float narrowing scope — the diagnostic script needs a zero-coverage warning/exit-code fix.

  • [TPR-05-017][medium] plans/repr-opt/section-05-float-narrowing.mdThe §05.5 memory-safety evidence still overstates Valgrind coverage: tests/valgrind/float_narrowing.ori does not exercise “all scenarios”. Resolved: Fixed on 2026-03-29. Expanded tests/valgrind/float_narrowing.ori to cover all 8 spec scenarios: added Mixed { exact, imprecise } (mixed exact/non-exact) and Pixel { r: int, x: float } (combined int+float). Valgrind passes clean on the expanded program.

  • [TPR-05-018][medium] plans/repr-opt/section-05-float-narrowing.mdSection metadata still says §05.5 is not-started even though the checklist body is already populated and mostly checked off. Resolved: Fixed on 2026-03-29. Updated frontmatter sections[4].status from not-started to in-progress.