diff --git a/Cargo.lock b/Cargo.lock index f3831254..3457e1cd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -323,6 +323,7 @@ name = "psrs-backend" version = "0.1.0" dependencies = [ "psrs-core", + "psrs-coverage", "psrs-hir", "psrs-linker", "psrs-runtime", @@ -360,6 +361,14 @@ dependencies = [ "psrs-thir", ] +[[package]] +name = "psrs-coverage" +version = "0.1.0" +dependencies = [ + "psrs-hir", + "psrs-span", +] + [[package]] name = "psrs-cst" version = "0.1.0" @@ -480,6 +489,7 @@ name = "psrs-typecheck" version = "0.1.0" dependencies = [ "psrs-ast", + "psrs-coverage", "psrs-desugar", "psrs-hir", "psrs-kind", diff --git a/Cargo.toml b/Cargo.toml index 9c8165d4..3c9cfe17 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "crates/psrs-cst", "crates/psrs-ast", "crates/psrs-hir", + "crates/psrs-coverage", "crates/psrs-resolve", "crates/psrs-thir", "crates/psrs-typecheck", @@ -30,6 +31,7 @@ psrs-span = { path = "crates/psrs-span" } psrs-cst = { path = "crates/psrs-cst" } psrs-ast = { path = "crates/psrs-ast" } psrs-hir = { path = "crates/psrs-hir" } +psrs-coverage = { path = "crates/psrs-coverage" } psrs-resolve = { path = "crates/psrs-resolve" } psrs-thir = { path = "crates/psrs-thir" } psrs-typecheck = { path = "crates/psrs-typecheck" } diff --git a/README.md b/README.md index d052528b..18ae5026 100644 --- a/README.md +++ b/README.md @@ -33,8 +33,8 @@ See [package and conformance setup](docs/workflow/stdlib-conformance.md). library from the locked `psrs-stdlib` package and writes the artifact. `wat ...` renders the text form. `dump ` prints an intermediate IR for debugging. A selected `main :: Int` returns its value as the process exit code. -A selected `main :: Effect Unit` runs that action once and returns 0; a trap -still propagates. +The package-configured runner executes a selected `main :: Effect a` once, +discards its result, and returns 0; a trap still propagates. For a guided, interactive walkthrough of P0 through P11, use the React application in [`psrs-explorer/`](psrs-explorer/). It labels compact teaching @@ -53,10 +53,15 @@ what remains in each layer. | L2 resolution | 72/72 failing, 403/413 passing | official `errorCode`s; 6 passing files stop at P3 and 4 at P0; no harness blockers | | L3 kinds | 39/48 failing | official kind `errorCode`s | | L4 types | 39/50 failing | official `errorCode`s | -| L5 classes | 58/81 failing | official `errorCode`s | -| L6/M7 runtime | 210/413 passing | all 210 exit 0; 203 do not agree, including 47 with no selected `main` | +| L5 classes | 81/97 failing | official `errorCode`s, measured 2026-10-17 | +| L6/M7 runtime | 287/340 programs | all exit 0; 53 do not agree; 74 support files remain program inputs, 25 FFI groups excluded | | M8 warnings, optimization | not measured | no scoreboard exists | +Frontend counts use source files; runtime counts use program roots with their +support sources. The 2026-10-17 runtime remeasurement uses the release harness +and records completion, not agreement with upstream output goldens. Its program +denominator is not directly comparable with the earlier per-file totals. + Run the scoreboards yourself: ```sh @@ -100,17 +105,8 @@ Verified working subsets, each with source tests and Wasmtime execution: ### What does not -- **surface lowering** — guards on equations and `case` alternatives, operator - and constructor-operator aliases, operator sections, unary minus, type - wildcards, pattern bindings, and multi-scrutinee `case`; 106 `passing` files - stop here; -- **resolution** — instance declarations are not resolved yet, the `Prim` - module hierarchy is not provided, and unary minus is not desugared; -- **diagnostics** — the type checker now emits official `errorCode`s for a type - mismatch, an occurs check, an out-of-range literal, a missing or overlapping - instance, a missing class member, an escaping skolem, and an ambiguous - constraint, so 49 M4/M5 cases are measurable; the eight class checks D-04 - lists as `0/n` do not exist yet; +- **frontend agreement** — kind, type, and class checking still have the gaps + recorded in D-04; warning-code agreement is not measured; - **runtime** — open rows have no runtime layout, variants are not implemented, and the scalar and numeric operation set is partial ([scalars](docs/design/backend/fp/scalars-and-primitives.md)); @@ -118,12 +114,10 @@ Verified working subsets, each with source tests and Wasmtime execution: scalar, string, list, flags, handle, and variant shapes ([canonical ABI](docs/design/backend/wasm/canonical-abi-and-wit.md)), and the synthesized aggregate fixtures validate but do not yet execute; -- **the standard library** — the independent `psrs-stdlib` package holds the - pinned official core libraries (215 source modules). A module the compiler owns, - such as `Safe.Coerce`, - resolves through its primitive interface rather than the vendored file, which - stays faithful to upstream; `Unsafe.Coerce.unsafeCoerce` has no interface yet - and is a recorded gap. +- **the standard library** — the independent `psrs-stdlib` package holds pinned + official sources and target adapters; several foreign bindings remain + unimplemented. Compiler-owned modules such as `Safe.Coerce` and + `Unsafe.Coerce` resolve through their declared primitive interfaces. ## Workspace diff --git a/crates/psrs-backend/Cargo.toml b/crates/psrs-backend/Cargo.toml index 6ea7c20c..ec3ad3ae 100644 --- a/crates/psrs-backend/Cargo.toml +++ b/crates/psrs-backend/Cargo.toml @@ -7,6 +7,7 @@ license.workspace = true [dependencies] psrs-core.workspace = true psrs-hir.workspace = true +psrs-coverage.workspace = true psrs-span.workspace = true psrs-linker.workspace = true psrs-runtime = { workspace = true, default-features = false, features = ["catalog", "gc-storage"] } diff --git a/crates/psrs-backend/src/abi/link/tests.rs b/crates/psrs-backend/src/abi/link/tests.rs index 91042f63..97eaae03 100644 --- a/crates/psrs-backend/src/abi/link/tests.rs +++ b/crates/psrs-backend/src/abi/link/tests.rs @@ -33,6 +33,7 @@ fn module() -> CoreModule { CoreType::Constructor(psrs_core::TypeConstructor::Unit), ], newtype_ids: Vec::new(), + ordinary_data_ids: Vec::new(), opaque_ids: Vec::new(), callable_types: Vec::new(), constructors: Vec::new(), diff --git a/crates/psrs-backend/src/abi/tests/aggregates.rs b/crates/psrs-backend/src/abi/tests/aggregates.rs index f00843ee..e99c95fe 100644 --- a/crates/psrs-backend/src/abi/tests/aggregates.rs +++ b/crates/psrs-backend/src/abi/tests/aggregates.rs @@ -39,6 +39,7 @@ fn aggregate_module() -> psrs_core::Module { let maybe = user(0); let either = user(1); let shape = user(2); + module.ordinary_data_ids = vec![maybe, either, shape]; module.type_names = vec![ (maybe, "Data.Maybe.Maybe".into()), (either, "Data.Either.Either".into()), diff --git a/crates/psrs-backend/src/abi/tests/enums.rs b/crates/psrs-backend/src/abi/tests/enums.rs index f12f7907..e02f8f9c 100644 --- a/crates/psrs-backend/src/abi/tests/enums.rs +++ b/crates/psrs-backend/src/abi/tests/enums.rs @@ -116,6 +116,7 @@ fn maps_nullary_source_constructors_to_matching_wit_enum_cases() { let span = TextRange::new(0, 1); let type_id = HirTypeId::new(ModuleId(0), 0); let mut core = empty_core_module(); + core.ordinary_data_ids.push(type_id); core.constructors = ["Red", "GreenBlue"] .into_iter() .enumerate() @@ -167,6 +168,7 @@ fn maps_nullary_source_constructors_to_matching_wit_enum_cases() { // The same enum with reversed constructor tags must not match the WIT // enum case order. let mut reversed_core = empty_core_module(); + reversed_core.ordinary_data_ids.push(type_id); reversed_core.constructors = ["Red", "GreenBlue"] .into_iter() .rev() @@ -197,6 +199,7 @@ fn validates_a_list_of_nullary_enums() { let type_id = HirTypeId::new(ModuleId(0), 0); let cases = ["Red", "GreenBlue"]; let mut module = empty_core_module(); + module.ordinary_data_ids.push(type_id); module.constructors = cases .iter() .enumerate() diff --git a/crates/psrs-backend/src/abi/tests/lists.rs b/crates/psrs-backend/src/abi/tests/lists.rs index 030d5f5c..16b69ef7 100644 --- a/crates/psrs-backend/src/abi/tests/lists.rs +++ b/crates/psrs-backend/src/abi/tests/lists.rs @@ -21,6 +21,7 @@ fn empty_core() -> CoreModule { external_types: Vec::new(), types: Vec::new(), newtype_ids: Vec::new(), + ordinary_data_ids: Vec::new(), opaque_ids: Vec::new(), callable_types: Vec::new(), constructors: Vec::new(), diff --git a/crates/psrs-backend/src/abi/tests/mod.rs b/crates/psrs-backend/src/abi/tests/mod.rs index a340165b..485689c2 100644 --- a/crates/psrs-backend/src/abi/tests/mod.rs +++ b/crates/psrs-backend/src/abi/tests/mod.rs @@ -23,6 +23,7 @@ fn empty_core_module() -> psrs_core::Module { external_types: Vec::new(), types: Vec::new(), newtype_ids: Vec::new(), + ordinary_data_ids: Vec::new(), opaque_ids: Vec::new(), callable_types: Vec::new(), constructors: Vec::new(), diff --git a/crates/psrs-backend/src/bindings/mod.rs b/crates/psrs-backend/src/bindings/mod.rs index 81477c1d..f1dafe9a 100644 --- a/crates/psrs-backend/src/bindings/mod.rs +++ b/crates/psrs-backend/src/bindings/mod.rs @@ -14,6 +14,7 @@ pub use runtime::metadata::RuntimeBinding; pub(crate) use runtime::metadata::{ from_core as runtime_metadata_from_core, validate_core as validate_runtime_bindings, }; +pub(crate) use runtime::record_projection::{operation_for_call, operation_matches}; pub(crate) use runtime::target::{ plan_import as plan_runtime_import, verify_import as verify_runtime_import, }; diff --git a/crates/psrs-backend/src/bindings/primitives/tests.rs b/crates/psrs-backend/src/bindings/primitives/tests.rs index e9ee0fd9..68de1178 100644 --- a/crates/psrs-backend/src/bindings/primitives/tests.rs +++ b/crates/psrs-backend/src/bindings/primitives/tests.rs @@ -39,6 +39,7 @@ fn fixture(types: &[TypeId]) -> Module { Type::Application(TypeId(3), TypeId(0)), ], newtype_ids: Vec::new(), + ordinary_data_ids: Vec::new(), opaque_ids: Vec::new(), callable_types: Vec::new(), constructors: Vec::new(), diff --git a/crates/psrs-backend/src/bindings/runtime/call/mod.rs b/crates/psrs-backend/src/bindings/runtime/call/mod.rs index e0e5258d..06bd4fac 100644 --- a/crates/psrs-backend/src/bindings/runtime/call/mod.rs +++ b/crates/psrs-backend/src/bindings/runtime/call/mod.rs @@ -1,6 +1,6 @@ //! Ordinary CC call signatures for the runtime storage ABI. use crate::{BackendError, RuntimeBinding, cc}; -use psrs_runtime::{StorageOperation, StorageValue}; +use psrs_runtime::{RecordSourceValue, StorageOperation, StorageValue}; #[cfg(test)] mod tests; @@ -39,6 +39,9 @@ pub(crate) fn canonical_signature( signature: &cc::Signature, table: &cc::RepresentationTable, ) -> Result> { + if binding.module == psrs_runtime::RECORD_STORAGE_MODULE { + return canonical_record_signature(binding, signature, table); + } if binding.module != psrs_runtime::STORAGE_MODULE { return Err(error(binding, "unknown runtime storage provider")); } @@ -89,3 +92,95 @@ pub(crate) fn canonical_signature( result: shape(contract.payload, signature.result)?, }) } + +fn canonical_record_signature( + binding: &RuntimeBinding, + signature: &cc::Signature, + table: &cc::RepresentationTable, +) -> Result> { + let operation = psrs_runtime::RecordPrimitive::from_export(&binding.function) + .ok_or_else(|| error(binding, "unknown record-storage export"))?; + let storage = table.record_storage().ok_or_else(|| { + error( + binding, + "record-storage provider has no shared owner schema", + ) + })?; + let contract = operation.source_contract(); + if signature.parameters.len() != contract.parameters.len() { + return Err(error( + binding, + "record primitive CC signature has wrong arity", + )); + } + let record_shape = cc::ValueShape::Reference(cc::Reference { + nullable: false, + heap: cc::RefShape::Repr(storage.representation()), + }); + let shape = |expected, actual| -> Result<_, Vec> { + match expected { + RecordSourceValue::Record => { + let cc::ValueShape::Reference(cc::Reference { + nullable: false, + heap: cc::RefShape::Repr(representation), + }) = actual + else { + return Err(error( + binding, + "record operand has no canonical record shape", + )); + }; + if representation != storage.representation() + && !matches!( + table.representation(representation), + Some(cc::Representation::Product { .. }) + ) + { + return Err(error( + binding, + "record operand is not a closed record product", + )); + } + Ok(record_shape) + } + RecordSourceValue::Int if actual == cc::ValueShape::Integer => Ok(actual), + RecordSourceValue::String if actual == cc::ValueShape::String => Ok(actual), + RecordSourceValue::Slot if actual == cc::payload::erased_shape() => Ok(actual), + RecordSourceValue::ArrayString | RecordSourceValue::ArraySlot => { + if let cc::ValueShape::Reference(cc::Reference { + nullable: false, + heap: cc::RefShape::Repr(id), + }) = actual + && matches!(table.representation(id), Some(cc::Representation::Array { element }) if *element == cc::payload::erased_shape()) + { + Ok(actual) + } else { + Err(error( + binding, + "record primitive arrays require erased-element storage", + )) + } + } + _ => Err(error( + binding, + "record primitive CC shape disagrees with source policy", + )), + } + }; + let parameters = contract + .parameters + .iter() + .copied() + .zip(signature.parameters.iter().copied()) + .map(|(expected, actual)| shape(expected, actual)) + .collect::, _>>()?; + let result = shape(contract.result, signature.result)?; + let canonical = cc::Signature { parameters, result }; + if canonical.parameters.len() != signature.parameters.len() { + return Err(error( + binding, + "record primitive CC signature has the wrong parameter count", + )); + } + Ok(canonical) +} diff --git a/crates/psrs-backend/src/bindings/runtime/call/tests.rs b/crates/psrs-backend/src/bindings/runtime/call/tests.rs index ff839891..2c4f1375 100644 --- a/crates/psrs-backend/src/bindings/runtime/call/tests.rs +++ b/crates/psrs-backend/src/bindings/runtime/call/tests.rs @@ -74,3 +74,41 @@ fn canonical_runtime_call_rejects_malformed_cc_signatures() { module.externals.clear(); assert!(checked(&binding, &module).is_err()); } + +#[test] +fn canonical_record_call_packs_closed_record_arguments_through_shared_storage() { + let binding = RuntimeBinding { + symbol: psrs_hir::SymbolId::new(psrs_hir::ModuleId(7), 2), + source_module: psrs_hir::ModuleId(7), + module: psrs_runtime::RECORD_STORAGE_MODULE.into(), + function: "length".into(), + type_id: None, + span: psrs_span::TextRange::new(0, 1), + }; + let mut table = cc::RepresentationTable::default(); + let storage = table.ensure_record_storage(); + let closed_record = table.reserve(); + table.set( + closed_record, + cc::Representation::Product { + fields: vec![cc::ValueShape::Integer], + }, + ); + let source = cc::Signature { + parameters: vec![cc::ValueShape::Reference(cc::Reference { + nullable: false, + heap: cc::RefShape::Repr(closed_record), + })], + result: cc::ValueShape::Integer, + }; + let canonical = canonical_signature(&binding, &source, &table) + .expect("a checked closed record is adapted through the shared owner"); + assert_eq!( + canonical.parameters, + vec![cc::ValueShape::Reference(cc::Reference { + nullable: false, + heap: cc::RefShape::Repr(storage.representation()), + })] + ); + assert_eq!(canonical.result, cc::ValueShape::Integer); +} diff --git a/crates/psrs-backend/src/bindings/runtime/mod.rs b/crates/psrs-backend/src/bindings/runtime/mod.rs index c305382b..7128b1c5 100644 --- a/crates/psrs-backend/src/bindings/runtime/mod.rs +++ b/crates/psrs-backend/src/bindings/runtime/mod.rs @@ -8,6 +8,8 @@ use psrs_runtime::StorageOperation; pub(super) mod call; pub(super) mod metadata; +pub(super) mod record_projection; +mod record_source; mod source; pub(super) mod target; @@ -36,6 +38,18 @@ pub(crate) fn validate(module: &Module) -> Result<(), Vec> { let error = |message| { vec![BackendError::new("P8 runtime linking", span, message).with_module(source_module)] }; + if provider == psrs_runtime::RECORD_STORAGE_MODULE { + let operation = + psrs_runtime::RecordPrimitive::from_export(function).ok_or_else(|| { + error(format!( + "runtime provider `{provider}` has no export `{function}`" + )) + })?; + let checked = checked + .ok_or_else(|| error("runtime binding has no checked source signature".into()))?; + record_source::validate(module, checked.ty, operation).map_err(error)?; + continue; + } if provider != psrs_runtime::STORAGE_MODULE { return Err(error(format!("unknown runtime provider `{provider}`"))); } diff --git a/crates/psrs-backend/src/bindings/runtime/record_projection.rs b/crates/psrs-backend/src/bindings/runtime/record_projection.rs new file mode 100644 index 00000000..7689ad7c --- /dev/null +++ b/crates/psrs-backend/src/bindings/runtime/record_projection.rs @@ -0,0 +1,154 @@ +//! The checked source-to-CC operation projection for record provider exports. + +use crate::cc::RecordOperation; +use crate::cc::ValueId; +use psrs_runtime::{RecordPrimitive, RecordProjection}; + +pub(crate) fn operation_for_call( + primitive: RecordPrimitive, + arguments: &[ValueId], +) -> Option<(Option, RecordOperation)> { + let contract = primitive.source_contract(); + if arguments.len() != contract.parameters.len() { + return None; + } + let argument = |index: usize| arguments.get(index).copied(); + Some(match contract.projection { + RecordProjection::Length { record } => (Some(argument(record)?), RecordOperation::Length), + RecordProjection::KeyAt { index, record } => ( + Some(argument(record)?), + RecordOperation::LabelAt { + index: argument(index)?, + }, + ), + RecordProjection::PayloadAt { index, record } => ( + Some(argument(record)?), + RecordOperation::Read { + index: argument(index)?, + }, + ), + RecordProjection::Build { labels, values } => ( + None, + RecordOperation::Build { + labels: argument(labels)?, + values: argument(values)?, + }, + ), + }) +} + +pub(crate) fn operation_matches( + primitive: RecordPrimitive, + record: Option, + operation: &RecordOperation, +) -> bool { + let contract = primitive.source_contract(); + let mut arguments = vec![None; contract.parameters.len()]; + let set = |arguments: &mut [Option], index: usize, value: ValueId| { + let Some(slot) = arguments.get_mut(index) else { + return false; + }; + *slot = Some(value); + true + }; + let valid = match (contract.projection, record, operation) { + (RecordProjection::Length { record: position }, Some(record), RecordOperation::Length) => { + set(&mut arguments, position, record) + } + ( + RecordProjection::KeyAt { + index, + record: position, + }, + Some(record), + RecordOperation::LabelAt { index: value }, + ) => set(&mut arguments, index, *value) && set(&mut arguments, position, record), + ( + RecordProjection::PayloadAt { + index, + record: position, + }, + Some(record), + RecordOperation::Read { index: value }, + ) => set(&mut arguments, index, *value) && set(&mut arguments, position, record), + ( + RecordProjection::Build { labels, values }, + None, + RecordOperation::Build { + labels: label_value, + values: payload_value, + }, + ) => { + set(&mut arguments, labels, *label_value) && set(&mut arguments, values, *payload_value) + } + _ => false, + }; + valid + && arguments.iter().all(Option::is_some) + && operation_for_call( + primitive, + &arguments.into_iter().flatten().collect::>(), + ) == Some((record, operation.clone())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn provider_descriptors_project_and_recover_each_shared_record_operation() { + let record = ValueId(9); + let index = ValueId(2); + let labels = ValueId(3); + let values = ValueId(4); + let cases = [ + ( + RecordPrimitive::Length, + vec![record], + Some(record), + RecordOperation::Length, + ), + ( + RecordPrimitive::KeyAt, + vec![index, record], + Some(record), + RecordOperation::LabelAt { index }, + ), + ( + RecordPrimitive::PayloadAt, + vec![index, record], + Some(record), + RecordOperation::Read { index }, + ), + ( + RecordPrimitive::Build, + vec![labels, values], + None, + RecordOperation::Build { labels, values }, + ), + ]; + for (primitive, arguments, record, operation) in cases { + assert_eq!( + operation_for_call(primitive, &arguments), + Some((record, operation.clone())) + ); + assert!(operation_matches(primitive, record, &operation)); + } + + assert!(!operation_matches( + RecordPrimitive::Build, + Some(record), + &RecordOperation::Build { labels, values }, + )); + assert!(!operation_matches( + RecordPrimitive::Build, + None, + &RecordOperation::Length, + )); + assert!(!operation_matches( + RecordPrimitive::PayloadAt, + Some(record), + &RecordOperation::LabelAt { index }, + )); + } +} diff --git a/crates/psrs-backend/src/bindings/runtime/record_source.rs b/crates/psrs-backend/src/bindings/runtime/record_source.rs new file mode 100644 index 00000000..06fad9ab --- /dev/null +++ b/crates/psrs-backend/src/bindings/runtime/record_source.rs @@ -0,0 +1,255 @@ +//! Source-level record provider validation before row and payload erasure. +use psrs_core::{Module, Type, TypeConstructor, TypeId}; +use psrs_hir::TypeId as HirTypeId; +use psrs_runtime::{RecordPrimitive, RecordSourceValue}; + +pub(super) fn validate( + module: &Module, + ty: TypeId, + operation: RecordPrimitive, +) -> Result<(), String> { + let (parameters, result) = psrs_core::call_parts(&module.types, ty).map_err(str::to_owned)?; + let contract = operation.source_contract(); + if parameters.len() != contract.parameters.len() { + return Err("record primitive signature has the wrong parameter count".into()); + } + let mut record_type = None; + let mut slot_type = None; + for (actual, expected) in parameters + .into_iter() + .zip(contract.parameters) + .chain(std::iter::once((result, &contract.result))) + { + match expected { + RecordSourceValue::Int => require_constructor(module, actual, TypeConstructor::Int)?, + RecordSourceValue::String => { + require_constructor(module, actual, TypeConstructor::String)? + } + RecordSourceValue::Record => { + if !module.is_record_type(actual) { + return Err("record primitive signature expects Record".into()); + } + if record_type.is_some_and(|previous| !module.types_equivalent(previous, actual)) { + return Err("record primitive uses inconsistent Record rows".into()); + } + record_type = Some(actual); + } + RecordSourceValue::Slot => { + require_slot(module, actual)?; + if slot_type.is_some_and(|previous| !module.types_equivalent(previous, actual)) { + return Err("record primitive uses inconsistent Slot types".into()); + } + slot_type = Some(actual); + } + RecordSourceValue::ArrayString => { + require_array_element( + module, + actual, + |element| { + module.types.get(element.0 as usize) + == Some(&Type::Constructor(TypeConstructor::String)) + }, + "Array String", + )?; + } + RecordSourceValue::ArraySlot => { + let element = require_array_element( + module, + actual, + |element| is_slot(module, element), + "Array Slot", + )?; + require_slot(module, element)?; + if slot_type.is_some_and(|previous| !module.types_equivalent(previous, element)) { + return Err("record primitive uses inconsistent Slot types".into()); + } + slot_type = Some(element); + } + } + } + Ok(()) +} + +fn require_constructor( + module: &Module, + id: TypeId, + expected: TypeConstructor, +) -> Result<(), String> { + if module.types.get(id.0 as usize) == Some(&Type::Constructor(expected)) { + Ok(()) + } else { + Err(format!("record primitive signature expects {expected:?}")) + } +} + +fn require_array_element( + module: &Module, + id: TypeId, + valid: impl FnOnce(TypeId) -> bool, + expected: &str, +) -> Result { + let Some((TypeConstructor::Array, arguments)) = module.applied_constructor(id) else { + return Err(format!("record primitive signature expects {expected}")); + }; + let [element] = arguments.as_slice() else { + return Err("record primitive Array must have one element type".into()); + }; + if !valid(*element) { + return Err(format!("record primitive signature expects {expected}")); + } + Ok(*element) +} + +fn require_slot(module: &Module, id: TypeId) -> Result<(), String> { + if is_slot(module, id) { + Ok(()) + } else { + Err("record primitive Slot must be a declared constructorless ordinary data type".into()) + } +} + +fn is_slot(module: &Module, id: TypeId) -> bool { + let Some((TypeConstructor::User(type_id), _arguments)) = module.applied_constructor(id) else { + return false; + }; + is_ordinary_constructorless_data(module, type_id) +} + +fn is_ordinary_constructorless_data(module: &Module, type_id: HirTypeId) -> bool { + module.ordinary_data_ids.contains(&type_id) + && !module.newtype_ids.contains(&type_id) + && !module.opaque_ids.contains(&type_id) + && !module + .constructors + .iter() + .any(|constructor| constructor.type_id == type_id) +} + +#[cfg(test)] +mod tests { + use super::*; + use psrs_core::{ConstructorInfo, Type}; + use psrs_hir::{ModuleId, SymbolId, TypeVariableId}; + use psrs_span::TextRange; + + const SLOT_ID: HirTypeId = HirTypeId::new(ModuleId(7), 4); + const FOREIGN_ID: HirTypeId = HirTypeId::new(ModuleId(7), 5); + + fn fixture() -> Module { + let types = vec![ + Type::Constructor(TypeConstructor::Int), + Type::Constructor(TypeConstructor::String), + Type::Constructor(TypeConstructor::User(SLOT_ID)), + Type::RowEmpty, + Type::Constructor(TypeConstructor::Record), + Type::Application(TypeId(4), TypeId(3)), + Type::Constructor(TypeConstructor::Array), + Type::Application(TypeId(6), TypeId(1)), + Type::Application(TypeId(6), TypeId(2)), + Type::Constructor(TypeConstructor::Function), + Type::Application(TypeId(2), TypeId(0)), + Type::Application(TypeId(6), TypeId(10)), + Type::Variable(TypeVariableId(0)), + Type::Constructor(TypeConstructor::User(FOREIGN_ID)), + ]; + Module { + type_names: vec![ + (SLOT_ID, "Carrier.Cell".into()), + (FOREIGN_ID, "Carrier.Foreign".into()), + ], + id: ModuleId(1), + name: "Carrier".into(), + externals: Vec::new(), + external_types: Vec::new(), + types, + newtype_ids: Vec::new(), + ordinary_data_ids: vec![SLOT_ID], + opaque_ids: Vec::new(), + callable_types: Vec::new(), + constructors: Vec::new(), + declarations: Vec::new(), + entry: None, + span: TextRange::new(0, 1), + } + } + + fn function(module: &mut Module, parameters: &[TypeId], result: TypeId) -> TypeId { + let function = TypeId(9); + let mut result = result; + for parameter in parameters.iter().rev() { + let partial = TypeId(module.types.len() as u32); + module.types.push(Type::Application(function, *parameter)); + let arrow = TypeId(module.types.len() as u32); + module.types.push(Type::Application(partial, result)); + result = arrow; + } + result + } + + #[test] + fn accepts_constructorless_declared_slot_and_concrete_record_build() { + let mut module = fixture(); + let bare_payload = function(&mut module, &[TypeId(0), TypeId(5)], TypeId(2)); + validate(&module, bare_payload, RecordPrimitive::PayloadAt).unwrap(); + + let payload = function(&mut module, &[TypeId(0), TypeId(5)], TypeId(10)); + validate(&module, payload, RecordPrimitive::PayloadAt).unwrap(); + let build = function(&mut module, &[TypeId(7), TypeId(11)], TypeId(5)); + validate(&module, build, RecordPrimitive::Build).unwrap(); + } + + #[test] + fn rejects_missing_conflicting_and_nonconstructorless_slot_evidence() { + let (base, payload) = payload_fixture(); + validate(&base, payload, RecordPrimitive::PayloadAt) + .expect("the shared fixture uses a valid declared carrier"); + + let mut missing = base.clone(); + missing.ordinary_data_ids.clear(); + assert_carrier_rejected(&missing, payload); + + let mut conflicting = base.clone(); + conflicting.opaque_ids.push(SLOT_ID); + assert_carrier_rejected(&conflicting, payload); + + let mut newtype = base.clone(); + newtype.newtype_ids.push(SLOT_ID); + assert_carrier_rejected(&newtype, payload); + + let mut constructor_bearing = base.clone(); + constructor_bearing.constructors.push(ConstructorInfo { + symbol: SymbolId::new(ModuleId(7), 5), + name: "Cell".into(), + type_id: SLOT_ID, + tag: 0, + field_count: 0, + field_types: Vec::new(), + parameters: Vec::new(), + }); + assert_carrier_rejected(&constructor_bearing, payload); + + let mut foreign = base.clone(); + foreign.opaque_ids.push(FOREIGN_ID); + let foreign_payload = function(&mut foreign, &[TypeId(0), TypeId(5)], TypeId(13)); + assert_carrier_rejected(&foreign, foreign_payload); + + let mut variable = base.clone(); + let type_variable = function(&mut variable, &[TypeId(0), TypeId(5)], TypeId(12)); + assert_carrier_rejected(&variable, type_variable); + } + + fn payload_fixture() -> (Module, TypeId) { + let mut module = fixture(); + let payload = function(&mut module, &[TypeId(0), TypeId(5)], TypeId(10)); + (module, payload) + } + + fn assert_carrier_rejected(module: &Module, payload: TypeId) { + let error = validate(module, payload, RecordPrimitive::PayloadAt) + .expect_err("malformed slot evidence must reject the checked source contract"); + assert_eq!( + error, + "record primitive Slot must be a declared constructorless ordinary data type" + ); + } +} diff --git a/crates/psrs-backend/src/bindings/tests.rs b/crates/psrs-backend/src/bindings/tests.rs index 2e75b6f1..ff0a01fd 100644 --- a/crates/psrs-backend/src/bindings/tests.rs +++ b/crates/psrs-backend/src/bindings/tests.rs @@ -44,6 +44,7 @@ pub(super) fn module(externals: Vec) -> CoreModule { external_types: Vec::new(), types: Vec::new(), newtype_ids: Vec::new(), + ordinary_data_ids: Vec::new(), opaque_ids: Vec::new(), callable_types: Vec::new(), constructors: Vec::new(), diff --git a/crates/psrs-backend/src/boundary/newtypes/tests.rs b/crates/psrs-backend/src/boundary/newtypes/tests.rs index 07a34467..bb97a09c 100644 --- a/crates/psrs-backend/src/boundary/newtypes/tests.rs +++ b/crates/psrs-backend/src/boundary/newtypes/tests.rs @@ -12,6 +12,7 @@ fn module(types: Vec, field: TypeId, parameters: Vec) -> M external_types: Vec::new(), types, newtype_ids: vec![OWNER], + ordinary_data_ids: Vec::new(), opaque_ids: Vec::new(), callable_types: Vec::new(), constructors: vec![ConstructorInfo { diff --git a/crates/psrs-backend/src/cc/assignment.rs b/crates/psrs-backend/src/cc/assignment.rs new file mode 100644 index 00000000..02825115 --- /dev/null +++ b/crates/psrs-backend/src/cc/assignment.rs @@ -0,0 +1,197 @@ +//! Target-neutral CC computations and structured branches. +use super::*; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Assignment { + pub destination: ValueId, + pub kind: AssignmentKind, + pub span: TextRange, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum AssignmentKind { + /// Retains the checked source operation at its explicit P8 erasure boundary. + /// The runtime result is the existing value; Wasm encoding erases this witness. + ControlEvidence { + value: ValueId, + implementation: ValueId, + arguments: Vec, + contract: ControlContractId, + }, + Constant(i32), + NumberConstant(String), + StringConstant(String), + Primitive { + op: BinaryOp, + left: ValueId, + right: ValueId, + }, + Unary { + op: UnaryOp, + value: ValueId, + }, + Record { + destination: ValueId, + record: ValueId, + operation: RecordOperation, + }, + /// A source-checked `psrs:record-storage` binding projected into the + /// application's shared record owner. Provider identity stays attached + /// until P9 validates and realizes the operation. + RecordPrimitive { + provider: SymbolId, + record: Option, + operation: RecordOperation, + }, + DirectCall { + /// Required for predeclared machine helpers; absent for ordinary calls. + contract: Option, + function: SymbolId, + arguments: Vec, + }, + FunctionRef { + function: SymbolId, + signature: SignatureId, + captures: Vec, + }, + IndirectCall { + function: ValueId, + signature: SignatureId, + arguments: Vec, + }, + /// A checked source runner supplies and discharges one logical root. + ClosureGetCapture { + closure: ValueId, + index: u32, + }, + RepresentationTest { + destination: ValueId, + value: ValueId, + reference: Reference, + }, + RepresentationCast { + destination: ValueId, + value: ValueId, + reference: Reference, + }, + AggregateConvert { + destination: ValueId, + value: ValueId, + conversion: AggregateConvert, + }, + ProductNew { + destination: ValueId, + representation: ReprId, + arguments: Vec, + }, + ProductGet { + destination: ValueId, + representation: ReprId, + field: u32, + value: ValueId, + }, + VariantNew { + destination: ValueId, + representation: ReprId, + case: u32, + fields: Vec, + }, + VariantTag { + destination: ValueId, + representation: ReprId, + value: ValueId, + }, + VariantGet { + destination: ValueId, + representation: ReprId, + case: u32, + field: u32, + value: ValueId, + }, + ArrayNew { + destination: ValueId, + representation: ReprId, + elements: Vec, + }, + ArrayFill { + destination: ValueId, + representation: ReprId, + length: ValueId, + value: ValueId, + }, + ArrayLen { + destination: ValueId, + value: ValueId, + }, + /// `Array.append`: a fresh array holding `left`'s elements followed by + /// `right`'s. Both operands and the result name the same array + /// representation, and neither operand is mutated. + ArrayAppend { + destination: ValueId, + representation: ReprId, + left: ValueId, + right: ValueId, + }, + /// A source `String`'s canonical UTF-8 bytes as an `Array Int`. A source + /// string is a sequence of Unicode scalar values, so this is lossless. + StringToBytes { + destination: ValueId, + representation: ReprId, + value: ValueId, + }, + /// Artifact-backed operation with checked language arguments before ABI erasure. + RuntimeCall { + intrinsic: psrs_hir::Intrinsic, + arguments: Vec, + }, + /// An `Array Int` read as a source `String`. Every element must be a + /// canonical byte and the bytes must be well-formed UTF-8. + BytesToString { + destination: ValueId, + representation: ReprId, + value: ValueId, + }, + ArrayGet { + destination: ValueId, + representation: ReprId, + value: ValueId, + index: ValueId, + }, + ArrayClone { + destination: ValueId, + representation: ReprId, + value: ValueId, + }, + ArraySet { + destination: ValueId, + representation: ReprId, + value: ValueId, + index: ValueId, + new_value: ValueId, + }, + If { + condition: ValueId, + then_assignments: Vec, + then_value: ValueId, + else_assignments: Vec, + else_value: ValueId, + }, + /// A multi-way choice on a closed, integer-tagged data type. Pattern + /// matching chooses these tags; P9 carries the choice into MIR's Switch. + TagSwitch { + value: ValueId, + cases: Vec, + default_assignments: Vec, + default_value: ValueId, + }, + /// An explicit trap result used by impossible decision-DAG edges. + Unreachable, +} + +/// One selected arm of a tag switch, with its branch-local computations. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TagCase { + pub tag: i32, + pub assignments: Vec, + pub value: ValueId, +} diff --git a/crates/psrs-backend/src/cc/case/coverage/engine.rs b/crates/psrs-backend/src/cc/case/coverage/engine.rs deleted file mode 100644 index 6652b8f1..00000000 --- a/crates/psrs-backend/src/cc/case/coverage/engine.rs +++ /dev/null @@ -1,493 +0,0 @@ -//! Shared pattern-matrix usefulness and witness reconstruction. - -use super::super::decision::{SurfacePattern, canonical_literal}; -use psrs_core::{Literal, Module, Type, TypeConstructor, TypeId}; -use psrs_hir::{SymbolId, TypeId as HirTypeId}; -use psrs_span::TextRange; - -#[derive(Clone, Debug, PartialEq, Eq, Hash)] -enum Head { - Constructor(SymbolId), - Record, - Literal(Literal), - ArrayLength(usize), -} - -#[derive(Clone, Debug)] -struct Shape { - ty: TypeId, - head: Head, - fields: Vec<(Option, TypeId)>, -} - -type Matrix = Vec>; -type AnalysisState = (Matrix, Vec); - -pub(super) fn useful( - module: &Module, - matrix: &Matrix, - query: &[SurfacePattern], -) -> Option> { - useful_with_active(module, matrix, query, &mut std::collections::HashSet::new()) -} - -fn useful_with_active( - module: &Module, - matrix: &Matrix, - query: &[SurfacePattern], - active: &mut std::collections::HashSet, -) -> Option> { - if matrix.iter().any(|row| { - row.len() == query.len() - && row - .iter() - .all(|pattern| matches!(pattern, SurfacePattern::Any { .. })) - }) { - return None; - } - let state = (matrix.clone(), query.to_vec()); - if !active.insert(state.clone()) { - return None; - } - let result = useful_inner(module, matrix, query, active); - active.remove(&state); - result -} - -fn useful_inner( - module: &Module, - matrix: &Matrix, - query: &[SurfacePattern], - active: &mut std::collections::HashSet, -) -> Option> { - let Some(head) = query.first() else { - return matrix.is_empty().then(Vec::new); - }; - let tail = &query[1..]; - if let SurfacePattern::Any { ty } = head { - if let Some(element_type) = array_element(module, *ty) { - return useful_array_any(module, matrix, query, tail, *ty, element_type, active); - } - if let Some(shapes) = signature(module, *ty) { - // An incomplete signature uses the default matrix. Expanding every - // constructor here grows unconstrained recursive product columns - // forever, even when the matrix is empty. Only a complete set of - // observed heads needs field-by-field specialization. - let missing = shapes - .iter() - .filter(|shape| { - !matrix - .iter() - .filter_map(|row| row.first()) - .any(|pattern| head_of(pattern).as_ref() == Some(&shape.head)) - }) - .collect::>(); - if !missing.is_empty() { - let witness = useful_with_active(module, &default_matrix(matrix), tail, active)?; - for shape in missing { - if let Some(head) = inhabited_shape(module, shape, &mut Default::default()) { - return Some(std::iter::once(head).chain(witness).collect()); - } - } - // Missing uninhabited constructors leave observed fields to - // check; their partial patterns can still expose a witness. - } - for shape in shapes { - let specialized = specialize(matrix, &shape); - let mut specialized_query = expanded_any(&shape, matrix, query); - specialized_query.extend_from_slice(tail); - if let Some(mut witness) = - useful_with_active(module, &specialized, &specialized_query, active) - { - let rest = witness.split_off(shape.fields.len()); - return Some( - std::iter::once(reconstruct(&shape, witness)) - .chain(rest) - .collect(), - ); - } - } - return None; - } - let default = default_matrix(matrix); - return useful_with_active(module, &default, tail, active) - .map(|witness| std::iter::once(head.clone()).chain(witness).collect()); - } - - let ty = pattern_type(head); - let shape = match head { - SurfacePattern::Array { elements, .. } => array_shape(module, ty, elements.len())?, - SurfacePattern::Literal { value, .. } => Shape { - ty, - head: Head::Literal(canonical_literal(value)), - fields: Vec::new(), - }, - _ => { - let selected = head_of(head)?; - signature(module, ty)? - .into_iter() - .find(|shape| shape.head == selected)? - } - }; - let specialized = specialize(matrix, &shape); - let mut specialized_query = expand(head, &shape, matrix, query); - specialized_query.extend_from_slice(tail); - useful_with_active(module, &specialized, &specialized_query, active).map(|mut witness| { - let rest = witness.split_off(shape.fields.len()); - std::iter::once(reconstruct(&shape, witness)) - .chain(rest) - .collect() - }) -} - -/// Constructs a finite inhabitant independently of the pattern matrix. The -/// active path is keyed by type, so recursive products cannot grow a sequence -/// of distinct query states while looking for a base constructor. -fn inhabited_type( - module: &Module, - ty: TypeId, - active: &mut std::collections::HashSet, -) -> Option { - let ty = crate::cc::layout::unquantified_type(module, ty); - if !active.insert(ty) { - return None; - } - let result = if array_element(module, ty).is_some() { - Some(SurfacePattern::Array { - elements: Vec::new(), - ty, - span: TextRange::default(), - }) - } else if let Some(shapes) = signature(module, ty) { - shapes - .iter() - .find_map(|shape| inhabited_shape(module, shape, active)) - } else { - Some(SurfacePattern::Any { ty }) - }; - active.remove(&ty); - result -} - -fn inhabited_shape( - module: &Module, - shape: &Shape, - active: &mut std::collections::HashSet, -) -> Option { - let fields = shape - .fields - .iter() - .map(|(_, ty)| inhabited_type(module, *ty, active)) - .collect::>>()?; - Some(reconstruct(shape, fields)) -} - -fn useful_array_any( - module: &Module, - matrix: &Matrix, - query: &[SurfacePattern], - tail: &[SurfacePattern], - ty: TypeId, - element_type: TypeId, - active: &mut std::collections::HashSet, -) -> Option> { - let lengths = matrix - .iter() - .filter_map(|row| row.first()) - .filter_map(|pattern| match pattern { - SurfacePattern::Array { elements, .. } => Some(elements.len()), - _ => None, - }) - .collect::>(); - let mut other_length = 0; - while lengths.contains(&other_length) { - other_length += 1; - } - let default = default_matrix(matrix); - if let Some(witness) = useful_with_active(module, &default, tail, active) { - let array = SurfacePattern::Array { - elements: (0..other_length) - .map(|_| SurfacePattern::Any { ty: element_type }) - .collect(), - ty, - span: TextRange::new(0, 0), - }; - return Some(std::iter::once(array).chain(witness).collect()); - } - for length in lengths { - let shape = Shape { - ty, - head: Head::ArrayLength(length), - fields: vec![(None, element_type); length], - }; - let specialized = specialize(matrix, &shape); - let mut specialized_query = expanded_any(&shape, matrix, query); - specialized_query.extend_from_slice(tail); - if let Some(mut witness) = - useful_with_active(module, &specialized, &specialized_query, active) - { - let rest = witness.split_off(length); - return Some( - std::iter::once(reconstruct(&shape, witness)) - .chain(rest) - .collect(), - ); - } - } - None -} - -fn signature(module: &Module, ty: TypeId) -> Option> { - let ty = crate::cc::layout::unquantified_type(module, ty); - if let Some(type_id) = user_type_id(module, ty) { - // An uninhabited user type has no constructors, but unchecked coercions - // can still produce its values (the reference `Free` over `Val` builds - // `Return` values this way). Coverage must not treat it as a complete - // empty signature, so its shape is left unknown and the wildcard matrix - // handles it. - if crate::cc::layout::is_uninhabited_data_type(module, type_id) { - return None; - } - return Some( - module - .constructors - .iter() - .filter(|constructor| constructor.type_id == type_id) - .map(|constructor| Shape { - ty, - head: Head::Constructor(constructor.symbol), - fields: constructor - .field_types - .iter() - .copied() - .map(|ty| (None, ty)) - .collect(), - }) - .collect(), - ); - } - if let Some(fields) = module.record_fields(ty) { - return Some(vec![Shape { - ty, - head: Head::Record, - fields: fields - .iter() - .map(|(label, ty)| (Some(label.clone()), *ty)) - .collect(), - }]); - } - if matches!( - module.types.get(ty.0 as usize), - Some(Type::Constructor(TypeConstructor::Boolean)) - ) { - return Some( - [false, true] - .into_iter() - .map(|value| Shape { - ty, - head: Head::Literal(Literal::Boolean(value)), - fields: Vec::new(), - }) - .collect(), - ); - } - None -} - -fn array_shape(module: &Module, ty: TypeId, length: usize) -> Option { - let element_type = array_element(module, ty)?; - Some(Shape { - ty, - head: Head::ArrayLength(length), - fields: vec![(None, element_type); length], - }) -} - -fn array_element(module: &Module, ty: TypeId) -> Option { - let ty = crate::cc::layout::unquantified_type(module, ty); - let Type::Application(function, element) = module.types.get(ty.0 as usize)? else { - return None; - }; - matches!( - module.types.get(function.0 as usize), - Some(Type::Constructor(TypeConstructor::Array)) - ) - .then_some(*element) -} - -fn user_type_id(module: &Module, mut ty: TypeId) -> Option { - ty = crate::cc::layout::unquantified_type(module, ty); - loop { - match module.types.get(ty.0 as usize)? { - Type::Constructor(TypeConstructor::User(id)) => return Some(*id), - Type::Application(function, _) => ty = *function, - _ => return None, - } - } -} - -fn pattern_type(pattern: &SurfacePattern) -> TypeId { - match pattern { - SurfacePattern::Any { ty } - | SurfacePattern::Var { ty, .. } - | SurfacePattern::Literal { ty, .. } - | SurfacePattern::Array { ty, .. } - | SurfacePattern::Named { ty, .. } - | SurfacePattern::Constructor { ty, .. } - | SurfacePattern::Record { ty, .. } => *ty, - } -} - -fn head_of(pattern: &SurfacePattern) -> Option { - match pattern { - SurfacePattern::Constructor { symbol, .. } => Some(Head::Constructor(*symbol)), - SurfacePattern::Record { .. } => Some(Head::Record), - SurfacePattern::Literal { value, .. } => Some(Head::Literal(canonical_literal(value))), - SurfacePattern::Array { elements, .. } => Some(Head::ArrayLength(elements.len())), - SurfacePattern::Named { pattern, .. } => head_of(pattern), - SurfacePattern::Any { .. } | SurfacePattern::Var { .. } => None, - } -} - -fn expand( - pattern: &SurfacePattern, - shape: &Shape, - matrix: &Matrix, - query: &[SurfacePattern], -) -> Vec { - match pattern { - SurfacePattern::Constructor { - symbol, arguments, .. - } if shape.head == Head::Constructor(*symbol) => arguments.clone(), - SurfacePattern::Record { fields, .. } if shape.head == Head::Record => shape - .fields - .iter() - .map(|(label, ty)| { - let label = label.as_deref().expect("record shapes have field labels"); - fields - .iter() - .find(|(field, _)| field == label) - .map_or_else(|| SurfacePattern::Any { ty: *ty }, |(_, pat)| pat.clone()) - }) - .collect(), - SurfacePattern::Literal { value, .. } - if shape.head == Head::Literal(canonical_literal(value)) => - { - Vec::new() - } - SurfacePattern::Array { elements, .. } - if shape.head == Head::ArrayLength(elements.len()) => - { - elements.clone() - } - SurfacePattern::Any { .. } | SurfacePattern::Var { .. } => { - expanded_any(shape, matrix, query) - } - _ => Vec::new(), - } -} - -fn expanded_any(shape: &Shape, matrix: &Matrix, query: &[SurfacePattern]) -> Vec { - let exemplar = query - .iter() - .chain(matrix.iter().filter_map(|row| row.first())) - .find(|pattern| head_of(pattern) == Some(shape.head.clone())); - shape - .fields - .iter() - .enumerate() - .map(|(index, (label, fallback_ty))| { - let ty = exemplar.and_then(|pattern| match pattern { - SurfacePattern::Constructor { arguments, .. } if label.is_none() => { - arguments.get(index).map(pattern_type) - } - SurfacePattern::Array { elements, .. } if label.is_none() => { - elements.get(index).map(pattern_type) - } - SurfacePattern::Record { fields, .. } => fields - .iter() - .find(|(field, _)| Some(field) == label.as_ref()) - .map(|(_, pattern)| pattern_type(pattern)), - _ => None, - }); - SurfacePattern::Any { - ty: ty.unwrap_or(*fallback_ty), - } - }) - .collect() -} - -fn specialize(matrix: &Matrix, shape: &Shape) -> Matrix { - let mut output = Vec::new(); - let candidates = matrix - .iter() - .filter_map(|row| row.first().cloned()) - .collect::>(); - for row in matrix { - let Some(head) = row.first() else { - continue; - }; - let expanded = if matches!( - head, - SurfacePattern::Any { .. } | SurfacePattern::Var { .. } - ) { - expanded_any(shape, matrix, &candidates) - } else if head_of(head) == Some(shape.head.clone()) { - expand(head, shape, matrix, &candidates) - } else { - continue; - }; - output.push( - expanded - .into_iter() - .chain(row[1..].iter().cloned()) - .collect(), - ); - } - output -} - -fn default_matrix(matrix: &Matrix) -> Matrix { - matrix - .iter() - .filter(|row| { - row.first() - .is_some_and(|pattern| matches!(pattern, SurfacePattern::Any { .. })) - }) - .map(|row| row[1..].to_vec()) - .collect() -} - -fn reconstruct(shape: &Shape, arguments: Vec) -> SurfacePattern { - let span = TextRange::new(0, 0); - match &shape.head { - Head::Constructor(symbol) => SurfacePattern::Constructor { - symbol: *symbol, - arguments, - ty: shape.ty, - span, - }, - Head::Record => SurfacePattern::Record { - fields: shape - .fields - .iter() - .zip(arguments) - .map(|((label, _), pattern)| { - (label.clone().expect("record shapes have labels"), pattern) - }) - .collect(), - ty: shape.ty, - span, - }, - Head::Literal(value) => SurfacePattern::Literal { - value: value.clone(), - ty: shape.ty, - span, - }, - Head::ArrayLength(_) => SurfacePattern::Array { - elements: arguments, - ty: shape.ty, - span, - }, - } -} diff --git a/crates/psrs-backend/src/cc/case/coverage/mod.rs b/crates/psrs-backend/src/cc/case/coverage/mod.rs index 7514dede..2a325305 100644 --- a/crates/psrs-backend/src/cc/case/coverage/mod.rs +++ b/crates/psrs-backend/src/cc/case/coverage/mod.rs @@ -1,11 +1,14 @@ //! Usefulness analysis over the decision compiler's shared pattern matrix. //! //! Coverage and first-match compilation use the same typed pattern algebra, -//! literal normalization, and constructor, scalar, and array heads. +//! literal normalization, and constructor, scalar, and array heads. The +//! semantic analysis lives in the `psrs-coverage` crate, the one owner shared +//! with the type checker's `Partial` flow; this module adapts the core +//! module's type facts and the backend's pattern algebra onto that engine. use super::decision::{SurfacePattern, surface_pattern}; -use psrs_core::{CaseBranch, Module, Pattern, TypeId}; -use psrs_hir::CaseBranchCoverage; +use psrs_core::{CaseBranch, Module, Pattern, Type, TypeConstructor, TypeId}; +use psrs_coverage::{Branch, ConstructorRow, Pattern as SharedPattern, Types as CoverageTypes}; #[derive(Clone, Debug, PartialEq, Eq)] pub(super) struct CoverageReport { @@ -23,67 +26,82 @@ impl CoverageReport { } } -mod engine; -mod render; -use engine::useful; -use render::render; +#[cfg(test)] +mod tests; pub(super) fn analyze( module: &Module, scrutinee_type: TypeId, branches: &[CaseBranch], ) -> CoverageReport { - if !branches.is_empty() - && branches + let analysis = psrs_coverage::analyze( + &ModuleTypes(module), + scrutinee_type, + &branches .iter() - .all(|branch| branch.coverage == CaseBranchCoverage::Generated) - { - return CoverageReport { - exhaustive: true, - witness: None, - redundant_branches: Vec::new(), - }; - } - let matrix = branches - .iter() - .filter(|branch| { - matches!( - branch.coverage, - CaseBranchCoverage::Source | CaseBranchCoverage::PartialFallback - ) - }) - .map(|branch| vec![coverage_pattern(&branch.pattern)]) - .collect::>>(); - let query = vec![SurfacePattern::Any { ty: scrutinee_type }]; - let witness = useful(module, &matrix, &query) - .map(|mut patterns| patterns.remove(0)) - .map(|pattern| render(module, &pattern)); - let mut prior = Vec::new(); - let mut redundant_branches = Vec::new(); - for (index, branch) in branches.iter().enumerate() { - if matches!( - branch.coverage, - CaseBranchCoverage::Generated | CaseBranchCoverage::PartialFallback - ) { - continue; - } - let query = vec![coverage_pattern(&branch.pattern)]; - if useful(module, &prior, &query).is_none() { - redundant_branches.push(index); - } - if branch.coverage == CaseBranchCoverage::Source { - prior.push(query); - } - } + .map(|branch| Branch { + coverage: branch.coverage, + pattern: coverage_pattern(&branch.pattern), + }) + .collect::>(), + ); CoverageReport { - exhaustive: witness.is_none(), - witness, - redundant_branches, + exhaustive: analysis.exhaustive, + // The analysis query is one column, so the first rendered atom is the + // pre-crate message exactly. + witness: analysis + .witness_atoms + .and_then(|mut row| (!row.is_empty()).then(|| row.swap_remove(0))), + redundant_branches: analysis.redundant_branches, + } +} + +fn coverage_pattern(pattern: &Pattern) -> SharedPattern { + shared_shape(erase_binders(surface_pattern(pattern))) +} + +/// Converts the backend algebra's binder-erased shape onto the shared one. +fn shared_shape(pattern: SurfacePattern) -> SharedPattern { + match pattern { + SurfacePattern::Any { ty } => SharedPattern::Any { ty }, + SurfacePattern::Var { ty, .. } => SharedPattern::Any { ty }, + SurfacePattern::Named { pattern, .. } => shared_shape(*pattern), + SurfacePattern::Literal { value, ty, .. } => SharedPattern::Literal { + literal: shared_literal(value), + ty, + }, + SurfacePattern::Array { elements, ty, .. } => SharedPattern::Array { + elements: elements.into_iter().map(shared_shape).collect(), + ty, + }, + SurfacePattern::Constructor { + symbol, + arguments, + ty, + .. + } => SharedPattern::Constructor { + symbol, + arguments: arguments.into_iter().map(shared_shape).collect(), + ty, + }, + SurfacePattern::Record { fields, ty, .. } => SharedPattern::Record { + fields: fields + .into_iter() + .map(|(label, value)| (label, shared_shape(value))) + .collect(), + ty, + }, } } -fn coverage_pattern(pattern: &Pattern) -> SurfacePattern { - erase_binders(surface_pattern(pattern)) +fn shared_literal(value: psrs_core::Literal) -> psrs_coverage::Literal { + match value { + psrs_core::Literal::Integer(value) => psrs_coverage::Literal::Integer(value), + psrs_core::Literal::Number(value) => psrs_coverage::Literal::Number(value), + psrs_core::Literal::String(value) => psrs_coverage::Literal::String(value), + psrs_core::Literal::Char(value) => psrs_coverage::Literal::Char(value), + psrs_core::Literal::Boolean(value) => psrs_coverage::Literal::Boolean(value), + } } fn erase_binders(pattern: SurfacePattern) -> SurfacePattern { @@ -118,5 +136,73 @@ fn erase_binders(pattern: SurfacePattern) -> SurfacePattern { } } -#[cfg(test)] -mod tests; +/// Supplies the core module's type facts to the shared engine. +struct ModuleTypes<'m>(&'m Module); + +impl CoverageTypes for ModuleTypes<'_> { + fn shape(&self, ty: TypeId) -> TypeId { + crate::cc::layout::unquantified_type(self.0, ty) + } + + fn array_element(&self, ty: TypeId) -> Option { + let ty = crate::cc::layout::unquantified_type(self.0, ty); + let Type::Application(function, element) = self.0.types.get(ty.0 as usize)? else { + return None; + }; + matches!( + self.0.types.get(function.0 as usize), + Some(Type::Constructor(TypeConstructor::Array)) + ) + .then_some(*element) + } + + fn constructors_of(&self, ty: TypeId) -> Option>> { + let ty = crate::cc::layout::unquantified_type(self.0, ty); + let type_id = crate::cc::layout::user_type_id(self.0, ty)?; + // An uninhabited user type has no constructors, but unchecked coercions + // can still produce its values (the reference `Free` over `Val` builds + // `Return` values this way). Coverage must not treat it as a complete + // empty signature, so its shape is left unknown and the wildcard + // matrix handles it. + if !self.0.newtype_ids.contains(&type_id) + && crate::cc::layout::is_uninhabited_data_type(self.0, type_id) + { + return None; + } + Some( + self.0 + .constructors + .iter() + .filter(|constructor| constructor.type_id == type_id) + .map(|constructor| ConstructorRow { + symbol: constructor.symbol, + fields: constructor.field_types.clone(), + }) + .collect(), + ) + } + + fn record_fields(&self, ty: TypeId) -> Option> { + self.0 + .record_fields(crate::cc::layout::unquantified_type(self.0, ty)) + } + + fn boolean_type(&self, ty: TypeId) -> bool { + let ty = crate::cc::layout::unquantified_type(self.0, ty); + matches!( + self.0.types.get(ty.0 as usize), + Some(Type::Constructor(TypeConstructor::Boolean)) + ) + } + + fn constructor_name(&self, symbol: psrs_hir::SymbolId) -> String { + self.0 + .constructors + .iter() + .find(|constructor| constructor.symbol == symbol) + .map_or_else( + || format!("Constructor{}", symbol.index), + |constructor| constructor.name.clone(), + ) + } +} diff --git a/crates/psrs-backend/src/cc/case/coverage/render.rs b/crates/psrs-backend/src/cc/case/coverage/render.rs deleted file mode 100644 index 2a47ab8b..00000000 --- a/crates/psrs-backend/src/cc/case/coverage/render.rs +++ /dev/null @@ -1,64 +0,0 @@ -use super::super::decision::SurfacePattern; -use psrs_core::{Literal, Module}; - -pub(super) fn render(module: &Module, pattern: &SurfacePattern) -> String { - match pattern { - SurfacePattern::Any { .. } | SurfacePattern::Var { .. } => "_".to_owned(), - SurfacePattern::Literal { value, .. } => render_literal(value), - SurfacePattern::Array { elements, .. } => format!( - "[{}]", - elements - .iter() - .map(|element| render(module, element)) - .collect::>() - .join(", ") - ), - SurfacePattern::Constructor { - symbol, arguments, .. - } => { - let name = module - .constructors - .iter() - .find(|constructor| constructor.symbol == *symbol) - .map_or_else( - || format!("Constructor{}", symbol.index), - |constructor| constructor.name.clone(), - ); - if arguments.is_empty() { - name - } else { - let arguments = arguments - .iter() - .map(|argument| match argument { - SurfacePattern::Any { .. } => "_".to_owned(), - SurfacePattern::Constructor { arguments, .. } if arguments.is_empty() => { - render(module, argument) - } - nested => format!("({})", render(module, nested)), - }) - .collect::>() - .join(" "); - format!("{name} {arguments}") - } - } - SurfacePattern::Record { fields, .. } => format!( - "{{ {} }}", - fields - .iter() - .map(|(label, value)| format!("{label}: {}", render(module, value))) - .collect::>() - .join(", ") - ), - SurfacePattern::Named { pattern, .. } => render(module, pattern), - } -} - -fn render_literal(literal: &Literal) -> String { - match literal { - Literal::Integer(value) => value.to_string(), - Literal::Number(value) => value.clone(), - Literal::String(value) => format!("{value:?}"), - Literal::Char(value) => format!("{value:?}"), - Literal::Boolean(value) => value.to_string(), - } -} diff --git a/crates/psrs-backend/src/cc/case/coverage/tests/mod.rs b/crates/psrs-backend/src/cc/case/coverage/tests/mod.rs index dc0de84e..5c5cec0c 100644 --- a/crates/psrs-backend/src/cc/case/coverage/tests/mod.rs +++ b/crates/psrs-backend/src/cc/case/coverage/tests/mod.rs @@ -24,6 +24,7 @@ fn module(types: Vec, constructors: Vec) -> Mo external_types: Vec::new(), types, newtype_ids: Vec::new(), + ordinary_data_ids: Vec::new(), opaque_ids: Vec::new(), callable_types: Vec::new(), constructors, @@ -115,7 +116,7 @@ fn push_record(types: &mut Vec, fields: Vec<(String, TypeId)>) -> TypeId { #[test] fn reports_the_missing_nullary_constructor() { - let module = module( + let mut module = module( vec![ Type::Constructor(TypeConstructor::User(hir_type_id(0))), Type::Constructor(psrs_core::TypeConstructor::Int), @@ -125,6 +126,7 @@ fn reports_the_missing_nullary_constructor() { constructor(1, "Blue", 0, Vec::new()), ], ); + module.ordinary_data_ids = vec![hir_type_id(0)]; let report = analyze(&module, TypeId(0), &[branch(nullary(0, 0))]); assert!(!report.exhaustive); assert_eq!(report.witness.as_deref(), Some("Blue")); @@ -420,7 +422,7 @@ fn a_field_of_an_uninhabited_type_does_not_make_its_constructor_redundant() { // unchecked coercions still produce `Return` values, so coverage must treat // the uninhabited field as an unknown shape rather than a complete empty // signature and must not call `Return` redundant. - let module = module( + let mut module = module( vec![ Type::Constructor(TypeConstructor::User(hir_type_id(0))), Type::Constructor(TypeConstructor::User(hir_type_id(1))), @@ -431,6 +433,7 @@ fn a_field_of_an_uninhabited_type_does_not_make_its_constructor_redundant() { constructor(1, "Bind", 0, vec![TypeId(1), TypeId(2)]), ], ); + module.ordinary_data_ids = vec![hir_type_id(0), hir_type_id(1)]; let return_branch = branch(pat( 0, PatternKind::Constructor { diff --git a/crates/psrs-backend/src/cc/case/decision/compile/mod.rs b/crates/psrs-backend/src/cc/case/decision/compile/mod.rs index a910719f..efb2c537 100644 --- a/crates/psrs-backend/src/cc/case/decision/compile/mod.rs +++ b/crates/psrs-backend/src/cc/case/decision/compile/mod.rs @@ -13,8 +13,8 @@ mod oracle_tests; mod scalar_array; #[cfg(test)] mod tests; +pub(in crate::cc::case) use matrix::surface_pattern; use matrix::{available_inputs, canonicalize, choose_column, map_actions}; -pub(in crate::cc::case) use matrix::{canonical_literal, surface_pattern}; struct Compiler<'a> { module: &'a Module, diff --git a/crates/psrs-backend/src/cc/case/decision/compile/oracle_record_tests.rs b/crates/psrs-backend/src/cc/case/decision/compile/oracle_record_tests.rs index 1cb0a0c8..4c7264b5 100644 --- a/crates/psrs-backend/src/cc/case/decision/compile/oracle_record_tests.rs +++ b/crates/psrs-backend/src/cc/case/decision/compile/oracle_record_tests.rs @@ -45,6 +45,7 @@ fn module() -> Module { Type::Application(psrs_core::TypeId(5), psrs_core::TypeId(4)), ], newtype_ids: Vec::new(), + ordinary_data_ids: Vec::new(), opaque_ids: Vec::new(), callable_types: Vec::new(), constructors: vec![ diff --git a/crates/psrs-backend/src/cc/case/decision/compile/oracle_tests.rs b/crates/psrs-backend/src/cc/case/decision/compile/oracle_tests.rs index 834a5b6d..596e15ea 100644 --- a/crates/psrs-backend/src/cc/case/decision/compile/oracle_tests.rs +++ b/crates/psrs-backend/src/cc/case/decision/compile/oracle_tests.rs @@ -40,6 +40,7 @@ fn module() -> Module { Type::Constructor(psrs_core::TypeConstructor::Int), ], newtype_ids: Vec::new(), + ordinary_data_ids: Vec::new(), opaque_ids: Vec::new(), callable_types: Vec::new(), constructors: vec![ diff --git a/crates/psrs-backend/src/cc/case/decision/compile/tests/mod.rs b/crates/psrs-backend/src/cc/case/decision/compile/tests/mod.rs index af7633b7..a8f1d8cf 100644 --- a/crates/psrs-backend/src/cc/case/decision/compile/tests/mod.rs +++ b/crates/psrs-backend/src/cc/case/decision/compile/tests/mod.rs @@ -17,6 +17,7 @@ fn bool_module() -> (Module, SymbolId, SymbolId) { external_types: Vec::new(), types: vec![Type::Constructor(TypeConstructor::User(type_id))], newtype_ids: Vec::new(), + ordinary_data_ids: Vec::new(), opaque_ids: Vec::new(), callable_types: Vec::new(), constructors: vec![ diff --git a/crates/psrs-backend/src/cc/case/decision/mod.rs b/crates/psrs-backend/src/cc/case/decision/mod.rs index 25ee7672..c6b5397a 100644 --- a/crates/psrs-backend/src/cc/case/decision/mod.rs +++ b/crates/psrs-backend/src/cc/case/decision/mod.rs @@ -8,7 +8,7 @@ use psrs_core::{Literal, TypeId}; use psrs_hir::{LocalId, SymbolId}; use psrs_span::TextRange; -pub(in crate::cc::case) use compile::{canonical_literal, compile_dag, surface_pattern}; +pub(in crate::cc::case) use compile::{compile_dag, surface_pattern}; #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub(super) struct ColumnKey(Vec); diff --git a/crates/psrs-backend/src/cc/case/decision/realize/mod.rs b/crates/psrs-backend/src/cc/case/decision/realize/mod.rs index bd68aae7..3703b9c7 100644 --- a/crates/psrs-backend/src/cc/case/decision/realize/mod.rs +++ b/crates/psrs-backend/src/cc/case/decision/realize/mod.rs @@ -324,6 +324,38 @@ impl FunctionLowerer<'_> { *span, &mut assignments, ) + } else if self.module.record_is_open(*source_type) == Some(true) { + let storage = self.representations.record_storage().ok_or_else(|| { + case_error(*span, "open record pattern has no labelled store") + })?; + if self + .record_types + .get(source_type) + .map(|representation| *representation != storage.representation()) + .unwrap_or(true) + { + return Err(case_error( + *span, + "open record pattern does not use its labelled store", + )); + } + let labels = self.module.record_fields(*source_type).ok_or_else(|| { + case_error(*span, "open record pattern has no checked prefix") + })?; + let Some((label, _)) = labels.get(*field as usize) else { + return Err(case_error( + *span, + "record pattern field is outside its checked prefix", + )); + }; + let target_shape = self.value_shape(*target_type, *span)?; + self.project_labelled_field( + source_value, + label, + target_shape, + *span, + &mut assignments, + )? } else { let representation = self.record_types.get(source_type).copied().ok_or_else(|| { diff --git a/crates/psrs-backend/src/cc/case/decision/realize/tests/mod.rs b/crates/psrs-backend/src/cc/case/decision/realize/tests/mod.rs index ff57cb83..42aac42e 100644 --- a/crates/psrs-backend/src/cc/case/decision/realize/tests/mod.rs +++ b/crates/psrs-backend/src/cc/case/decision/realize/tests/mod.rs @@ -33,6 +33,7 @@ fn compiled_root_switch_resolves_the_realizer_root_slot() { external_types: Vec::new(), types: vec![Type::Constructor(TypeConstructor::User(type_id))], newtype_ids: Vec::new(), + ordinary_data_ids: Vec::new(), opaque_ids: Vec::new(), callable_types: Vec::new(), constructors: vec![ diff --git a/crates/psrs-backend/src/cc/case/decision/realize/tests/nested_tests.rs b/crates/psrs-backend/src/cc/case/decision/realize/tests/nested_tests.rs index 5a1ac855..9aa5e6cd 100644 --- a/crates/psrs-backend/src/cc/case/decision/realize/tests/nested_tests.rs +++ b/crates/psrs-backend/src/cc/case/decision/realize/tests/nested_tests.rs @@ -35,6 +35,7 @@ fn nested_sum_patterns_project_once_per_selected_constructor_and_trap_missing_ta Type::Constructor(psrs_core::TypeConstructor::Int), ], newtype_ids: Vec::new(), + ordinary_data_ids: Vec::new(), opaque_ids: Vec::new(), callable_types: Vec::new(), constructors: vec![ diff --git a/crates/psrs-backend/src/cc/case/decision/realize/tests/newtype_tests.rs b/crates/psrs-backend/src/cc/case/decision/realize/tests/newtype_tests.rs index 9ffa9411..410def1f 100644 --- a/crates/psrs-backend/src/cc/case/decision/realize/tests/newtype_tests.rs +++ b/crates/psrs-backend/src/cc/case/decision/realize/tests/newtype_tests.rs @@ -31,6 +31,7 @@ fn newtype_constructor_erases_before_nested_enum_dispatch() { Type::Constructor(psrs_core::TypeConstructor::Int), ], newtype_ids: vec![wrapper_type], + ordinary_data_ids: Vec::new(), opaque_ids: Vec::new(), callable_types: Vec::new(), constructors: vec![ diff --git a/crates/psrs-backend/src/cc/case/decision/realize/tests/parameterized_tests/mod.rs b/crates/psrs-backend/src/cc/case/decision/realize/tests/parameterized_tests/mod.rs index bc3414ea..5f59d3e0 100644 --- a/crates/psrs-backend/src/cc/case/decision/realize/tests/parameterized_tests/mod.rs +++ b/crates/psrs-backend/src/cc/case/decision/realize/tests/parameterized_tests/mod.rs @@ -31,6 +31,7 @@ fn parameterized_array_field_projection_uses_its_stored_canonical_array() { Type::Application(psrs_core::TypeId(0), psrs_core::TypeId(1)), ], newtype_ids: Vec::new(), + ordinary_data_ids: Vec::new(), opaque_ids: Vec::new(), callable_types: Vec::new(), constructors: vec![ConstructorInfo { @@ -184,6 +185,7 @@ fn nested_parameterized_projection_keeps_each_canonical_field() { Type::Application(psrs_core::TypeId(1), psrs_core::TypeId(2)), ], newtype_ids: Vec::new(), + ordinary_data_ids: Vec::new(), opaque_ids: Vec::new(), callable_types: Vec::new(), constructors: vec![ @@ -337,6 +339,7 @@ fn generic_record_pattern_projects_its_canonical_array_field() { Type::Application(psrs_core::TypeId(5), psrs_core::TypeId(4)), ], newtype_ids: Vec::new(), + ordinary_data_ids: Vec::new(), opaque_ids: Vec::new(), callable_types: Vec::new(), constructors: Vec::new(), diff --git a/crates/psrs-backend/src/cc/case/decision/realize/tests/product_tests.rs b/crates/psrs-backend/src/cc/case/decision/realize/tests/product_tests.rs index 7d402055..ba7282ef 100644 --- a/crates/psrs-backend/src/cc/case/decision/realize/tests/product_tests.rs +++ b/crates/psrs-backend/src/cc/case/decision/realize/tests/product_tests.rs @@ -30,6 +30,7 @@ fn single_constructor_product_dispatch_projects_and_binds_first_row_once() { Type::Constructor(psrs_core::TypeConstructor::Int), ], newtype_ids: Vec::new(), + ordinary_data_ids: Vec::new(), opaque_ids: Vec::new(), callable_types: Vec::new(), constructors: vec![ConstructorInfo { diff --git a/crates/psrs-backend/src/cc/case/decision/realize/tests/record_tests.rs b/crates/psrs-backend/src/cc/case/decision/realize/tests/record_tests.rs index a80bd98a..e1bd30ed 100644 --- a/crates/psrs-backend/src/cc/case/decision/realize/tests/record_tests.rs +++ b/crates/psrs-backend/src/cc/case/decision/realize/tests/record_tests.rs @@ -8,6 +8,7 @@ use psrs_core::{ CaseBranch, ConstructorInfo, Expr, ExprKind, Module, Pattern, PatternKind, Type, TypeConstructor, }; +use psrs_hir::TypeVariableId; use psrs_hir::{ModuleId, SymbolId, TypeId as HirTypeId}; use psrs_span::TextRange; use std::cell::RefCell; @@ -45,6 +46,7 @@ fn nested_record_patterns_share_one_product_projection_and_keep_source_spans() { Type::Application(psrs_core::TypeId(5), psrs_core::TypeId(4)), ], newtype_ids: Vec::new(), + ordinary_data_ids: Vec::new(), opaque_ids: Vec::new(), callable_types: Vec::new(), constructors: vec![ @@ -218,3 +220,174 @@ fn nested_record_patterns_share_one_product_projection_and_keep_source_spans() { .any(|assignment| matches!(assignment.kind, AssignmentKind::TagSwitch { .. })) ); } + +/// An open-row record pattern reads the labelled store by its literal label +/// instead of guessing a product index. +#[test] +fn open_record_patterns_find_their_field_label_in_the_labelled_store() { + let module_id = ModuleId(0); + let record = psrs_core::TypeId(4); + let int_pattern_variable = Pattern { + kind: PatternKind::Var { + id: psrs_hir::LocalId(0), + ty: psrs_core::TypeId(0), + }, + ty: psrs_core::TypeId(0), + span: TextRange::new(3, 6), + }; + let module = Module { + type_names: Vec::new(), + id: module_id, + name: "OpenRecordDecisionTest".into(), + externals: Vec::new(), + external_types: Vec::new(), + types: vec![ + Type::Constructor(psrs_core::TypeConstructor::Int), + Type::Variable(TypeVariableId(0)), + Type::RowExtend { + label: "value".into(), + ty: psrs_core::TypeId(0), + tail: psrs_core::TypeId(1), + }, + Type::Constructor(TypeConstructor::Record), + Type::Application(psrs_core::TypeId(3), psrs_core::TypeId(2)), + ], + newtype_ids: Vec::new(), + ordinary_data_ids: Vec::new(), + opaque_ids: Vec::new(), + callable_types: Vec::new(), + constructors: Vec::new(), + declarations: Vec::new(), + entry: None, + span: TextRange::new(0, 40), + }; + let branches = vec![CaseBranch { + coverage: psrs_hir::CaseBranchCoverage::Source, + pattern: Pattern { + kind: PatternKind::Record { + fields: vec![("value".into(), int_pattern_variable)], + }, + ty: record, + span: TextRange::new(1, 8), + }, + value: Expr { + kind: ExprKind::Integer(0), + ty: psrs_core::TypeId(0), + span: TextRange::new(10, 11), + }, + span: TextRange::new(1, 11), + }]; + + let mut representations = RepresentationTable::default(); + let storage = representations.ensure_record_storage(); + let storage_repr = storage.representation(); + let boxed_integer_type = Some({ + let id = representations.reserve(); + representations.set( + id, + Representation::Box { + value: ValueShape::Integer, + }, + ); + id + }); + let signatures = HashMap::new(); + let enum_types = HashSet::new(); + let aggregate_types = HashSet::new(); + let newtype_ids = HashSet::new(); + let array_types = HashMap::new(); + let record_types = HashMap::from([(record, storage_repr)]); + let constructor_tags = HashMap::new(); + let constructors_by_type = HashMap::new(); + let constructor_types = HashMap::new(); + let function_types = HashMap::new(); + let function_wrappers = HashMap::new(); + let generated_symbols = Rc::new(RefCell::new(GeneratedSymbolAllocator::new(&module))); + let root_shape = ValueShape::Reference(Reference { + nullable: false, + heap: RefShape::Repr(storage_repr), + }); + let boundary = BoundaryEvidence::empty(&module); + let mut lowerer = FunctionLowerer { + next_value: 1, + values: vec![ValueDecl { + id: crate::types::ValueId(0), + ty: root_shape, + }], + locals: HashMap::new(), + signatures: &signatures, + representations: &representations, + module: &module, + boundary: &boundary, + enum_types: &enum_types, + aggregate_types: &aggregate_types, + newtype_ids: &newtype_ids, + nominal_types: &std::collections::HashMap::new(), + boxed_integer_type, + boxed_number_type: None, + control_layout: None, + control_functions: std::rc::Rc::new(std::cell::RefCell::new(None)), + generated_signatures: std::rc::Rc::new(std::cell::RefCell::new( + std::collections::HashMap::new(), + )), + array_types: &array_types, + record_types: &record_types, + constructor_tags: &constructor_tags, + constructors_by_type: &constructors_by_type, + constructor_types: &constructor_types, + function_types: &function_types, + function_wrappers: &function_wrappers, + generated_symbols, + owner: module_id, + warnings: Vec::new(), + local_types: HashMap::new(), + generated: Vec::new(), + }; + let mut assignments = Vec::new(); + let result = lowerer + .lower_case( + record, + crate::types::ValueId(0), + &branches, + ValueShape::Integer, + TextRange::new(0, 12), + &mut assignments, + ) + .expect("open record case should lower through the pattern DAG"); + let function = Function { + symbol: SymbolId::new(module_id, 0), + name: "openRecordCase".into(), + parameters: vec![crate::types::ValueId(0)], + values: lowerer.values, + assignments, + result, + result_type: ValueShape::Integer, + span: TextRange::new(0, 12), + }; + crate::cc::verify::verify_function(&function, &signatures, &representations) + .expect("open record DAG result should verify as CC"); + + assert_eq!( + function + .assignments + .iter() + .filter(|assignment| matches!(assignment.kind, AssignmentKind::ProductGet { .. })) + .count(), + 0, + "open rows project by label, never through a product index" + ); + assert!(function.assignments.iter().any(|assignment| matches!( + assignment.kind, + AssignmentKind::Record { + operation: crate::cc::RecordOperation::Find { .. }, + .. + } + ))); + assert!(function.assignments.iter().any(|assignment| matches!( + assignment.kind, + AssignmentKind::Record { + operation: crate::cc::RecordOperation::Read { .. }, + .. + } + ))); +} diff --git a/crates/psrs-backend/src/cc/convert.rs b/crates/psrs-backend/src/cc/convert.rs index f90b7deb..09e5a49a 100644 --- a/crates/psrs-backend/src/cc/convert.rs +++ b/crates/psrs-backend/src/cc/convert.rs @@ -56,6 +56,20 @@ pub enum ValueConversion { labels: Vec, fields: Vec, }, + /// A complete closed product enters the owner's labelled erased storage. + RecordPack { + source: ReprId, + target: ReprId, + labels: Vec, + fields: Vec, + }, + /// Recover a complete closed product after validating all stored labels. + RecordUnpack { + source: ReprId, + target: ReprId, + labels: Vec, + fields: Vec, + }, } #[derive(Clone, Debug, PartialEq, Eq, Hash)] @@ -82,7 +96,10 @@ impl ValueConversion { | Self::RecoverReference { destination, .. } | Self::FunctionAdapter { destination, .. } => *destination, Self::EraseReference => super::payload::erased_shape(), - Self::ArrayMap { target, .. } | Self::ProductMap { target, .. } => reference(*target), + Self::ArrayMap { target, .. } + | Self::ProductMap { target, .. } + | Self::RecordPack { target, .. } + | Self::RecordUnpack { target, .. } => reference(*target), Self::Sequence(steps) => steps .iter() .fold(source, |shape, step| step.output_shape(shape)), diff --git a/crates/psrs-backend/src/cc/layout/aggregate.rs b/crates/psrs-backend/src/cc/layout/aggregate.rs index 898c0ba4..3b0a388a 100644 --- a/crates/psrs-backend/src/cc/layout/aggregate.rs +++ b/crates/psrs-backend/src/cc/layout/aggregate.rs @@ -36,14 +36,19 @@ pub(super) fn reserve_aggregate_layouts( } if array_element_type(module, id).is_some() { arrays.insert(id, representations.reserve()); - } else if module.is_record_type(id) && !module.record_is_open(id).unwrap_or(false) { - records.insert(id, representations.reserve()); + } else if module.is_record_type(id) { + let storage = representations.ensure_record_storage(); + if module.record_is_open(id).unwrap_or(false) { + records.insert(id, storage.representation()); + } else { + records.insert(id, representations.reserve()); + } } } AggregateLayouts { arrays, records } } -/// Fills the reserved handles with canonical array and closed-record layouts. +/// Fills the reserved handles with canonical array and record layouts. /// Core IDs select normalization inputs, not runtime layout identities. #[allow(clippy::too_many_arguments)] pub(super) fn normalize_aggregate_layouts( @@ -98,6 +103,16 @@ fn normalize_pass( representations: &mut RepresentationTable, ) -> Result<(AggregateLayouts, HashMap), Vec> { let reserved = layouts.clone(); + let mut canonical = HashMap::new(); + if let Some(storage) = representations.record_storage() { + // These arrays already have an owner. Source arrays must reuse the + // erased-element handle rather than creating an incompatible duplicate. + canonical.insert(AggregateKey::Array(ValueShape::String), storage.labels()); + canonical.insert( + AggregateKey::Array(crate::cc::payload::erased_shape()), + storage.values(), + ); + } let mut builder = Builder { module, enum_types, @@ -108,7 +123,7 @@ fn normalize_pass( representations, arrays: layouts.arrays, records: layouts.records, - canonical: HashMap::new(), + canonical, normalized: HashSet::new(), active: HashSet::new(), }; @@ -186,6 +201,9 @@ struct Builder<'a> { impl Builder<'_> { fn normalize(&mut self, id: TypeId, span: TextRange) -> Result> { + if self.module.record_is_open(id) == Some(true) { + return self.handle(id, span); + } if self.normalized.contains(&id) { return self.handle(id, span); } diff --git a/crates/psrs-backend/src/cc/layout/control/mod.rs b/crates/psrs-backend/src/cc/layout/control/mod.rs index e4a49b98..715b047f 100644 --- a/crates/psrs-backend/src/cc/layout/control/mod.rs +++ b/crates/psrs-backend/src/cc/layout/control/mod.rs @@ -48,6 +48,7 @@ pub(in crate::cc) fn append( continuation: SignatureId, nominal_types: &HashMap, ) -> ControlLayout { + table.ensure_record_storage(); let erased = erased_shape(); let computation = nominal_types[&HirTypeId::CONTROL]; let sequence = table.reserve(); @@ -58,7 +59,7 @@ pub(in crate::cc) fn append( let status = crate::cc::layout::protocols::array(table, ValueShape::Integer); let replay_result = table.reserve(); let labels = vec!["_1".to_owned(), "_2".to_owned()]; - let pair = crate::cc::layout::protocols::record(table, &labels).unwrap_or_else(|| { + let pair = crate::cc::layout::protocols::erased_product(table, &labels).unwrap_or_else(|| { let pair = table.reserve(); table.set( pair, @@ -202,7 +203,7 @@ mod tests { .iter() .filter(|repr| matches!(repr, Representation::Array { .. })) .count(), - 2 + 3 ); } } diff --git a/crates/psrs-backend/src/cc/layout/mod.rs b/crates/psrs-backend/src/cc/layout/mod.rs index a9994c83..d95aef9c 100644 --- a/crates/psrs-backend/src/cc/layout/mod.rs +++ b/crates/psrs-backend/src/cc/layout/mod.rs @@ -111,6 +111,9 @@ fn layoutable_field_type_inner( newtype_ids: &HashSet, visiting: &mut HashSet, ) -> bool { + if user_type_id(module, id).is_some_and(|id| is_uninhabited_data_type(module, id)) { + return true; + } if is_callable_type(module, id) || depends_on_type_variable(module, id) { return true; } @@ -132,11 +135,9 @@ fn layoutable_field_type_inner( result } Some(Type::Constructor(TypeConstructor::User(_))) => true, - // A closed record has its own representation handle, so a variant case - // may carry one as a referenced payload (WIT `datetime`, socket - // addresses, and directory entries are records). An open row has no - // fixed layout. - Some(_) if module.is_record_type(id) => !module.record_is_open(id).unwrap_or(false), + // Closed products and open-row storage each have an owned reference + // layout, so a variant field retains the complete record value. + Some(_) if module.is_record_type(id) => true, Some(Type::Variable(_)) | Some(Type::ForAll { .. }) | Some(Type::Closure { .. }) @@ -194,6 +195,10 @@ pub(super) fn type_layout( .declarations .iter() .any(|declaration| depends_on_type_variable(module, declaration.ty)) + || module.types.iter().any(|ty| { + matches!(ty, Type::Constructor(TypeConstructor::User(id)) + if is_uninhabited_data_type(module, *id)) + }) || module_has_integer_capture(module) || module .types @@ -318,7 +323,7 @@ pub(super) fn type_layout( representations.set(id, Representation::Variant { cases }); } - protocols::append(&mut representations); + protocols::append(&mut representations, !array_types.is_empty()); let function_slot = crate::boundary::function_slot_protocol(&mut representations); let protocols = crate::boundary::payload_erased_protocols( &mut representations, @@ -390,17 +395,34 @@ pub(super) fn user_type_id(module: &CoreModule, mut id: TypeId) -> Option bool { - !module.opaque_ids.contains(&type_id) - && !module - .constructors - .iter() - .any(|constructor| constructor.type_id == type_id) +/// Constructorless data and transparent newtype cycles have no finite values. +/// A cycle is detected by declaration identity, never by a source name. The +/// canonical storage template is followed without specializing its parameters. +pub(super) fn is_uninhabited_data_type(module: &CoreModule, mut type_id: HirTypeId) -> bool { + let mut visited = HashSet::new(); + loop { + if module.opaque_ids.contains(&type_id) { + return false; + } + if module.newtype_ids.contains(&type_id) { + if !visited.insert(type_id) { + return true; + } + let Some(field) = newtype_field_type(module, type_id) else { + return false; + }; + let Some(inner) = user_type_id(module, field) else { + return false; + }; + type_id = inner; + } else { + return module.ordinary_data_ids.contains(&type_id) + && !module + .constructors + .iter() + .any(|constructor| constructor.type_id == type_id); + } + } } /// Whether a Core type is a callable closure value: an ordinary function arrow diff --git a/crates/psrs-backend/src/cc/layout/protocols.rs b/crates/psrs-backend/src/cc/layout/protocols.rs index fdf53f86..938ad69c 100644 --- a/crates/psrs-backend/src/cc/layout/protocols.rs +++ b/crates/psrs-backend/src/cc/layout/protocols.rs @@ -1,69 +1,46 @@ //! Aggregate owners' canonical protocols for bare polymorphic value slots. use super::super::{RefShape, Reference, ReprId, Representation, RepresentationTable, ValueShape}; -pub(super) fn append(table: &mut RepresentationTable) { +pub(super) fn append(table: &mut RepresentationTable, has_source_arrays: bool) { let erased = ValueShape::Reference(Reference { nullable: false, heap: RefShape::Erased, }); - // Register the canonical erased-element array only for a module that has an - // array layout to normalize. A bare slot can hold an array only when a - // concrete array type exists, so a program without arrays must not gain a - // dead representation that would claim a newtype allocated storage. - let has_array = table - .representations - .iter() - .any(|representation| matches!(representation, Representation::Array { .. })); - if has_array { + // Internal record arrays have their own schema. Only source arrays require + // the source Array and text-codec protocols. + if has_source_arrays { array(table, erased); - // Text codecs explicitly cross from source slots to integer buffers. array(table, ValueShape::Integer); } - let labels = table - .product_labels - .values() - .cloned() - .collect::>(); - for labels in labels { - if table.product_labels.iter().any(|(id, existing)| { - *existing == labels - && matches!(table.representation(*id), Some(Representation::Product { fields }) - if fields.len() == labels.len() && fields.iter().all(|field| *field == erased)) - }) { - continue; - } - let id = table.reserve(); - table.set( - id, - Representation::Product { - fields: vec![erased; labels.len()], - }, - ); - table.set_product_labels(id, labels); - } } -pub(crate) fn record(table: &RepresentationTable, labels: &[String]) -> Option { - let erased = ValueShape::Reference(Reference { - nullable: false, - heap: RefShape::Erased, - }); +/// A complete typed product whose fields happen to be erased. This is not +/// the record owner's bare-slot protocol; the control result-pair ABI uses it. +pub(in crate::cc) fn erased_product( + table: &RepresentationTable, + labels: &[String], +) -> Option { + let erased = crate::cc::payload::erased_shape(); table.product_labels.iter().find_map(|(id, existing)| { (*existing == labels && matches!(table.representation(*id), Some(Representation::Product { fields }) - if fields.len() == labels.len() && fields.iter().all(|field| *field == erased))) + if fields.len() == labels.len() && fields.iter().all(|field| *field == erased))) .then_some(*id) }) } /// Reuse the authoritative array representation for a physical element shape. -pub(super) fn array(table: &mut RepresentationTable, element: ValueShape) -> ReprId { - if let Some(index) = table.representations.iter().position(|representation| { - matches!(representation, Representation::Array { element: existing } if *existing == element) - }) { - return ReprId(index as u32); +pub(in crate::cc) fn array(table: &mut RepresentationTable, element: ValueShape) -> ReprId { + if let Some(id) = array_for(table, element) { + return id; } let id = table.reserve(); table.set(id, Representation::Array { element }); id } + +pub(in crate::cc) fn array_for(table: &RepresentationTable, element: ValueShape) -> Option { + table.representations.iter().position(|representation| { + matches!(representation, Representation::Array { element: existing } if *existing == element) + }).map(|index| ReprId(index as u32)) +} diff --git a/crates/psrs-backend/src/cc/layout/scalar.rs b/crates/psrs-backend/src/cc/layout/scalar.rs index 2ffe36dc..f0de86b2 100644 --- a/crates/psrs-backend/src/cc/layout/scalar.rs +++ b/crates/psrs-backend/src/cc/layout/scalar.rs @@ -98,13 +98,7 @@ pub(crate) fn declaration_shape( }) } Some(_) if module.is_record_type(ty) => { - if module.record_is_open(ty).unwrap_or(false) { - Err(vec![BackendError::new( - "P8 closure conversion", - declaration.span, - "open record rows have no runtime layout", - )]) - } else if let Some(repr) = record_types.get(&ty) { + if let Some(repr) = record_types.get(&ty) { Ok(Signature { parameters, result: ValueShape::Reference(Reference { @@ -285,13 +279,7 @@ pub(crate) fn scalar_type( })) } Some(_) if module.is_record_type(id) => { - if module.record_is_open(id).unwrap_or(false) { - Err(vec![BackendError::new( - "P8 closure conversion", - span, - "open record rows have no runtime layout", - )]) - } else if let Some(repr) = record_types.get(&id) { + if let Some(repr) = record_types.get(&id) { Ok(ValueShape::Reference(Reference { nullable: false, heap: RefShape::Repr(*repr), diff --git a/crates/psrs-backend/src/cc/layout/tests/arrays.rs b/crates/psrs-backend/src/cc/layout/tests/arrays.rs index ede67235..47d59ef3 100644 --- a/crates/psrs-backend/src/cc/layout/tests/arrays.rs +++ b/crates/psrs-backend/src/cc/layout/tests/arrays.rs @@ -82,6 +82,7 @@ fn a_wit_import_scheme_lays_out_array_payloads_without_a_declaration_root() { }], types, newtype_ids: Vec::new(), + ordinary_data_ids: Vec::new(), opaque_ids: Vec::new(), callable_types: Vec::new(), constructors: Vec::new(), diff --git a/crates/psrs-backend/src/cc/layout/tests/mod.rs b/crates/psrs-backend/src/cc/layout/tests/mod.rs index 3c8d754b..0799d95f 100644 --- a/crates/psrs-backend/src/cc/layout/tests/mod.rs +++ b/crates/psrs-backend/src/cc/layout/tests/mod.rs @@ -8,6 +8,7 @@ use psrs_core::{ use psrs_hir::{LocalId, ModuleId, SymbolId, TypeId as HirTypeId, TypeVariableId}; mod opaque; +mod open_records; mod records; mod uninhabited; @@ -30,6 +31,7 @@ fn empty_module(types: Vec) -> Module { external_types: Vec::new(), types, newtype_ids: Vec::new(), + ordinary_data_ids: Vec::new(), opaque_ids: Vec::new(), callable_types: Vec::new(), constructors: Vec::new(), @@ -224,6 +226,7 @@ fn equal_normalized_function_signatures_share_one_signature_id() { external_types: Vec::new(), types, newtype_ids: Vec::new(), + ordinary_data_ids: Vec::new(), opaque_ids: Vec::new(), callable_types: Vec::new(), constructors: Vec::new(), @@ -342,6 +345,7 @@ fn integer_capture_module(capture: Type) -> Module { external_types: Vec::new(), types: vec![capture, Type::Constructor(psrs_core::TypeConstructor::Int)], newtype_ids: Vec::new(), + ordinary_data_ids: Vec::new(), opaque_ids: Vec::new(), callable_types: Vec::new(), constructors: Vec::new(), @@ -431,6 +435,7 @@ fn an_opaque_handle_and_an_array_of_handles_have_scalar_layouts() { Type::Application(TypeId(1), TypeId(0)), ], newtype_ids: Vec::new(), + ordinary_data_ids: Vec::new(), opaque_ids: vec![opaque], callable_types: Vec::new(), constructors: Vec::new(), diff --git a/crates/psrs-backend/src/cc/layout/tests/open_records.rs b/crates/psrs-backend/src/cc/layout/tests/open_records.rs new file mode 100644 index 00000000..e3894c39 --- /dev/null +++ b/crates/psrs-backend/src/cc/layout/tests/open_records.rs @@ -0,0 +1,64 @@ +use super::*; + +#[test] +fn open_rows_share_the_full_record_store_without_freezing_their_prefix() { + let mut types = vec![ + Type::Constructor(TypeConstructor::Int), + Type::Constructor(TypeConstructor::Boolean), + Type::Variable(TypeVariableId(0)), + Type::Variable(TypeVariableId(1)), + Type::Constructor(TypeConstructor::Record), + Type::RowExtend { + label: "value".into(), + ty: TypeId(0), + tail: TypeId(2), + }, + Type::Application(TypeId(4), TypeId(5)), + Type::RowExtend { + label: "enabled".into(), + ty: TypeId(1), + tail: TypeId(3), + }, + Type::Application(TypeId(4), TypeId(7)), + Type::Constructor(TypeConstructor::Array), + Type::Application(TypeId(9), TypeId(0)), + ]; + let first = push_arrow(&mut types, TypeId(6), TypeId(6)); + let second = push_arrow(&mut types, TypeId(8), TypeId(8)); + let mut module = empty_module(types); + root_types(&mut module, [first, second, TypeId(10)]); + let layout = layout_for(&module); + let storage = layout.representations.record_storage().unwrap(); + assert_eq!(layout.record_types[&TypeId(6)], storage.representation()); + assert_eq!(layout.record_types[&TypeId(8)], storage.representation()); + assert_eq!(layout.array_types[&TypeId(10)], storage.values()); + assert!( + layout + .representations + .product_labels(storage.representation()) + .is_none() + ); + for callable in [first, second] { + let signature = layout + .representations + .signature(layout.function_types[&callable]) + .unwrap(); + assert_eq!( + signature.parameters, + vec![ValueShape::Reference(Reference { + nullable: false, + heap: RefShape::Repr(storage.representation()), + })] + ); + assert_eq!(signature.result, signature.parameters[0]); + } + super::super::super::verify_module(&super::super::super::Module { + name: "OpenRecordLayout".into(), + externals: Vec::new(), + representations: layout.representations, + functions: Vec::new(), + entry: None, + span: module.span, + }) + .expect("normalized storage schema remains valid at the CC boundary"); +} diff --git a/crates/psrs-backend/src/cc/layout/tests/records.rs b/crates/psrs-backend/src/cc/layout/tests/records.rs index b619191b..0db83400 100644 --- a/crates/psrs-backend/src/cc/layout/tests/records.rs +++ b/crates/psrs-backend/src/cc/layout/tests/records.rs @@ -45,6 +45,7 @@ fn parameter_dependent_record_field_keeps_its_canonical_template_in_the_adt_slot external_types: Vec::new(), types, newtype_ids: Vec::new(), + ordinary_data_ids: Vec::new(), opaque_ids: Vec::new(), callable_types: Vec::new(), constructors: vec![ConstructorInfo { @@ -122,6 +123,7 @@ fn a_variant_case_may_carry_a_record_payload() { external_types: Vec::new(), types, newtype_ids: Vec::new(), + ordinary_data_ids: Vec::new(), opaque_ids: Vec::new(), callable_types: Vec::new(), constructors: vec![ConstructorInfo { diff --git a/crates/psrs-backend/src/cc/layout/tests/uninhabited.rs b/crates/psrs-backend/src/cc/layout/tests/uninhabited.rs index 4e68ba7b..42ff81da 100644 --- a/crates/psrs-backend/src/cc/layout/tests/uninhabited.rs +++ b/crates/psrs-backend/src/cc/layout/tests/uninhabited.rs @@ -53,6 +53,7 @@ fn erased() -> ValueShape { fn a_bare_constructorless_data_type_uses_the_erased_reference() { let empty = HirTypeId::new(ModuleId(1), 0); let mut module = empty_module(vec![Type::Constructor(TypeConstructor::User(empty))]); + module.ordinary_data_ids.push(empty); let declaration = declaration(&mut module, TypeId(0)); assert_eq!(shape(&module, &declaration), erased()); } @@ -65,6 +66,7 @@ fn an_applied_constructorless_data_type_uses_the_erased_reference() { Type::Constructor(TypeConstructor::Int), Type::Application(TypeId(0), TypeId(1)), ]); + module.ordinary_data_ids.push(empty); let declaration = declaration(&mut module, TypeId(2)); assert_eq!(shape(&module, &declaration), erased()); } @@ -75,6 +77,7 @@ fn a_user_type_with_a_constructor_is_not_treated_as_uninhabited() { // constructor, so the erased fallback must not swallow it. let inhabited = HirTypeId::new(ModuleId(1), 0); let mut module = empty_module(vec![Type::Constructor(TypeConstructor::User(inhabited))]); + module.ordinary_data_ids.push(inhabited); module.constructors.push(ConstructorInfo { symbol: SymbolId::new(module.id, 1), name: "Only".to_owned(), @@ -86,3 +89,46 @@ fn a_user_type_with_a_constructor_is_not_treated_as_uninhabited() { }); assert!(!super::is_uninhabited_data_type(&module, inhabited)); } + +#[test] +fn transparent_newtype_cycles_use_erased_storage_without_recursing() { + for mutual in [false, true] { + let a = HirTypeId::new(ModuleId(1), 0); + let b = HirTypeId::new(ModuleId(1), 1); + let mut module = empty_module(vec![ + Type::Constructor(TypeConstructor::User(a)), + Type::Constructor(TypeConstructor::User(b)), + ]); + module.newtype_ids = vec![a, b]; + for (id, field) in [(a, if mutual { 1 } else { 0 }), (b, 0)] { + module.constructors.push(ConstructorInfo { + symbol: SymbolId::new(id.module, id.index + 2), + name: "Loop".into(), + type_id: id, + tag: 0, + field_count: 1, + field_types: vec![TypeId(field)], + parameters: Vec::new(), + }); + } + let declaration = declaration(&mut module, TypeId(0)); + assert_eq!(shape(&module, &declaration), erased()); + let newtypes = module.newtype_ids.iter().copied().collect(); + assert_eq!( + super::scalar_type( + &module, + TypeId(0), + module.span, + &HashSet::new(), + &HashSet::new(), + &newtypes, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new() + ) + .unwrap(), + erased() + ); + } +} diff --git a/crates/psrs-backend/src/cc/lower/call/application.rs b/crates/psrs-backend/src/cc/lower/call/application.rs index 58dc599c..23c644ea 100644 --- a/crates/psrs-backend/src/cc/lower/call/application.rs +++ b/crates/psrs-backend/src/cc/lower/call/application.rs @@ -108,7 +108,7 @@ impl ApplicationLowering for FunctionLowerer<'_> { }; values.push((converted, restore_shape)); } - let values = values + let values: Vec = values .into_iter() .map(|(value, shape)| match shape { Some(shape) => { @@ -118,13 +118,23 @@ impl ApplicationLowering for FunctionLowerer<'_> { }) .collect(); let call_result = self.fresh(signature.result); + let kind = self + .record_primitive_assignment(function, &values) + .map_or_else( + || AssignmentKind::DirectCall { + contract: None, + function, + arguments: values, + }, + |(provider, record, operation)| AssignmentKind::RecordPrimitive { + provider, + record, + operation, + }, + ); assignments.push(Assignment { destination: call_result, - kind: AssignmentKind::DirectCall { - contract: None, - function, - arguments: values, - }, + kind, span: expression.span, }); let source_type = diff --git a/crates/psrs-backend/src/cc/lower/call/helpers.rs b/crates/psrs-backend/src/cc/lower/call/helpers.rs index e0668b8c..027d94e9 100644 --- a/crates/psrs-backend/src/cc/lower/call/helpers.rs +++ b/crates/psrs-backend/src/cc/lower/call/helpers.rs @@ -271,7 +271,10 @@ pub(super) fn function_value_types( pub(super) fn conversion_reconstructs_aggregate(conversion: &ValueConversion) -> bool { match conversion { - ValueConversion::ArrayMap { .. } | ValueConversion::ProductMap { .. } => true, + ValueConversion::ArrayMap { .. } + | ValueConversion::ProductMap { .. } + | ValueConversion::RecordPack { .. } + | ValueConversion::RecordUnpack { .. } => true, ValueConversion::Sequence(steps) => steps.iter().any(conversion_reconstructs_aggregate), ValueConversion::Identity | ValueConversion::BoxScalar { .. } diff --git a/crates/psrs-backend/src/cc/lower/call/mod.rs b/crates/psrs-backend/src/cc/lower/call/mod.rs index 90bfda36..3183c0ce 100644 --- a/crates/psrs-backend/src/cc/lower/call/mod.rs +++ b/crates/psrs-backend/src/cc/lower/call/mod.rs @@ -10,6 +10,35 @@ use psrs_span::TextRange; pub(super) use helpers::{is_function_type, persist_reference, restore_reference}; impl super::FunctionLowerer<'_> { + pub(super) fn record_primitive_assignment( + &self, + function: psrs_hir::SymbolId, + arguments: &[ValueId], + ) -> Option<( + psrs_hir::SymbolId, + Option, + crate::cc::RecordOperation, + )> { + let external = self + .module + .externals + .iter() + .find(|external| external.symbol == function)?; + let psrs_hir::ExternalKind::Runtime { + module, + function: export, + } = &external.kind + else { + return None; + }; + if module != psrs_runtime::RECORD_STORAGE_MODULE { + return None; + } + let primitive = psrs_runtime::RecordPrimitive::from_export(export)?; + let (record, operation) = crate::bindings::operation_for_call(primitive, arguments)?; + Some((function, record, operation)) + } + pub(super) fn lower_external_function_value( &mut self, expression: &Expr, diff --git a/crates/psrs-backend/src/cc/lower/call/partial/global.rs b/crates/psrs-backend/src/cc/lower/call/partial/global.rs index d260cb1c..9740aba0 100644 --- a/crates/psrs-backend/src/cc/lower/call/partial/global.rs +++ b/crates/psrs-backend/src/cc/lower/call/partial/global.rs @@ -176,13 +176,23 @@ impl FunctionLowerer<'_> { call_arguments.push(converted); } let result = nested.fresh(source_signature.result); + let kind = self + .record_primitive_assignment(function, &call_arguments) + .map_or_else( + || AssignmentKind::DirectCall { + contract: None, + function, + arguments: call_arguments, + }, + |(provider, record, operation)| AssignmentKind::RecordPrimitive { + provider, + record, + operation, + }, + ); nested_assignments.push(Assignment { destination: result, - kind: AssignmentKind::DirectCall { - contract: None, - function, - arguments: call_arguments, - }, + kind, span: expression.span, }); let (result, source_result_shape, source_result_type) = if remaining_parameters.len() diff --git a/crates/psrs-backend/src/cc/lower/control/builder.rs b/crates/psrs-backend/src/cc/lower/control/builder.rs index 0320c097..163ee9ec 100644 --- a/crates/psrs-backend/src/cc/lower/control/builder.rs +++ b/crates/psrs-backend/src/cc/lower/control/builder.rs @@ -131,7 +131,7 @@ impl<'a> Machine<'a> { arguments: vec![state, value], } }); - self.cast(value, erased_shape(), body) + self.pair_conversion(value, true, body) } pub fn pair_field( @@ -141,7 +141,7 @@ impl<'a> Machine<'a> { body: &mut Vec, ) -> ValueId { let representation = self.layout.pair; - let value = self.cast(value, repr(representation), body); + let value = self.pair_conversion(value, false, body); self.emit(erased_shape(), body, |destination| { AssignmentKind::ProductGet { destination, @@ -152,6 +152,22 @@ impl<'a> Machine<'a> { }) } + fn pair_conversion( + &mut self, + value: ValueId, + entering: bool, + body: &mut Vec, + ) -> ValueId { + let conversion = super::pair::conversion(self.representations, self.layout.pair, entering); + self.emit(conversion.destination, body, |destination| { + AssignmentKind::AggregateConvert { + destination, + value, + conversion, + } + }) + } + pub fn capture( &mut self, closure: ValueId, diff --git a/crates/psrs-backend/src/cc/lower/control/mod.rs b/crates/psrs-backend/src/cc/lower/control/mod.rs index 90497ea7..2830d764 100644 --- a/crates/psrs-backend/src/cc/lower/control/mod.rs +++ b/crates/psrs-backend/src/cc/lower/control/mod.rs @@ -18,6 +18,7 @@ mod foreign; mod lifetime; mod operands; mod owned; +mod pair; mod plan; mod planning; mod replay; diff --git a/crates/psrs-backend/src/cc/lower/control/pair.rs b/crates/psrs-backend/src/cc/lower/control/pair.rs new file mode 100644 index 00000000..984eb765 --- /dev/null +++ b/crates/psrs-backend/src/cc/lower/control/pair.rs @@ -0,0 +1,23 @@ +//! Bridge the private machine pair and the public erased record protocol. +use crate::cc::payload::{PayloadPlanner, StoragePayloadPlanner, erased_shape}; +use crate::cc::{AggregateConvert, ReprId, RepresentationTable}; + +pub(super) fn conversion( + table: &RepresentationTable, + pair: ReprId, + entering: bool, +) -> AggregateConvert { + let shape = super::builder::repr(pair); + let mut planner = StoragePayloadPlanner(table); + let span = psrs_span::TextRange::default(); + let (source, destination, plan) = if entering { + (shape, erased_shape(), planner.erase_payload(shape, span)) + } else { + (erased_shape(), shape, planner.recover_payload(shape, span)) + }; + AggregateConvert { + source, + destination, + plan: plan.expect("machine result pair must have a record storage protocol"), + } +} diff --git a/crates/psrs-backend/src/cc/lower/control/plan/boundary/ops.rs b/crates/psrs-backend/src/cc/lower/control/plan/boundary/ops.rs index deb80fd6..3d09877e 100644 --- a/crates/psrs-backend/src/cc/lower/control/plan/boundary/ops.rs +++ b/crates/psrs-backend/src/cc/lower/control/plan/boundary/ops.rs @@ -96,13 +96,17 @@ impl Checker<'_> { self.values(actual, &self.operands(captures, env)?)?; Ok(a.destination) } - Op::Pair { state, value } => self.product( - self.schema.result_pair, - &[operand(state)?, operand(value)?], - body, - ), + Op::Pair { state, value } => { + let pair = self.product( + self.schema.result_pair, + &[operand(state)?, operand(value)?], + body, + )?; + self.pair_conversion(pair, true, body) + } Op::PairField { pair, field } => { - self.product_field(operand(pair)?, self.schema.result_pair, *field, body) + let pair = self.pair_conversion(operand(pair)?, false, body)?; + self.product_field(pair, self.schema.result_pair, *field, body) } Op::ReplayField { result, field } => { self.product_field(operand(result)?, self.schema.replay_result, *field, body) diff --git a/crates/psrs-backend/src/cc/lower/control/plan/boundary/primitives.rs b/crates/psrs-backend/src/cc/lower/control/plan/boundary/primitives.rs index 4e5f9509..8fa2f8be 100644 --- a/crates/psrs-backend/src/cc/lower/control/plan/boundary/primitives.rs +++ b/crates/psrs-backend/src/cc/lower/control/plan/boundary/primitives.rs @@ -75,6 +75,28 @@ impl Checker<'_> { Ok(a.destination) } + pub(super) fn pair_conversion( + &self, + source: ValueId, + entering: bool, + body: &mut &[Assignment], + ) -> Checked { + let assignment = self.next(body)?; + let A::AggregateConvert { + value, conversion, .. + } = &assignment.kind + else { + return Err(MISMATCH); + }; + let expected = + super::super::super::pair::conversion(self.table, self.schema.result_pair, entering); + if conversion != &expected { + return Err(MISMATCH); + } + self.equal(*value, source)?; + Ok(assignment.destination) + } + pub(super) fn product( &self, repr: ReprId, diff --git a/crates/psrs-backend/src/cc/lower/conversion/mod.rs b/crates/psrs-backend/src/cc/lower/conversion/mod.rs index 6edfdf13..ac70e4d0 100644 --- a/crates/psrs-backend/src/cc/lower/conversion/mod.rs +++ b/crates/psrs-backend/src/cc/lower/conversion/mod.rs @@ -14,6 +14,7 @@ use psrs_span::TextRange; mod callable; mod function_slot; mod product_map; +mod record; mod scalars; mod transport; @@ -108,6 +109,9 @@ impl FunctionLowerer<'_> { if source_shape == destination_shape { return Ok(ValueConversion::Identity); } + if let Some(plan) = self.record_storage_conversion(source_shape, destination_shape, span)? { + return Ok(plan); + } // Newtypes use their declared field template as their storage protocol. // Keep the supplied physical shapes: instantiating the field here would // incorrectly replace erased storage with the concrete argument shape. @@ -310,7 +314,9 @@ impl FunctionLowerer<'_> { let mut ty = unquantified_type(self.module, ty); let mut visited = std::collections::HashSet::new(); while let Some(id) = user_type_id(self.module, ty) { - if !self.newtype_ids.contains(&id) { + if !self.newtype_ids.contains(&id) + || crate::cc::layout::is_uninhabited_data_type(self.module, id) + { break; } if !visited.insert(id) { diff --git a/crates/psrs-backend/src/cc/lower/conversion/record.rs b/crates/psrs-backend/src/cc/lower/conversion/record.rs new file mode 100644 index 00000000..12f29585 --- /dev/null +++ b/crates/psrs-backend/src/cc/lower/conversion/record.rs @@ -0,0 +1,74 @@ +use super::*; +use crate::cc::Representation; + +impl FunctionLowerer<'_> { + pub(super) fn record_storage_conversion( + &mut self, + source: ValueShape, + target: ValueShape, + span: TextRange, + ) -> Result, Vec> { + let Some(storage) = self.representations.record_storage() else { + return Ok(None); + }; + let repr = |shape| match shape { + ValueShape::Reference(Reference { + heap: RefShape::Repr(id), + nullable: false, + }) => Some(id), + _ => None, + }; + let (Some(source), Some(target)) = (repr(source), repr(target)) else { + return Ok(None); + }; + let packing = target == storage.representation(); + let unpacking = source == storage.representation(); + if packing == unpacking { + return Ok(None); + } + let product = if packing { source } else { target }; + let labels = self + .representations + .product_labels(product) + .ok_or_else(|| { + conversion_error( + span, + "record storage boundary has no complete closed labels", + ) + })? + .to_vec(); + let Some(Representation::Product { fields }) = self.representations.representation(product) + else { + return Err(conversion_error( + span, + "record storage boundary requires a closed product", + )); + }; + let fields = fields + .clone() + .into_iter() + .map(|field| { + if packing { + self.erase_payload(field, span) + } else { + self.recover_payload(field, span) + } + }) + .collect::, _>>()?; + Ok(Some(if packing { + ValueConversion::RecordPack { + source, + target, + labels, + fields, + } + } else { + ValueConversion::RecordUnpack { + source, + target, + labels, + fields, + } + })) + } +} diff --git a/crates/psrs-backend/src/cc/lower/conversion/tests.rs b/crates/psrs-backend/src/cc/lower/conversion/tests.rs index 4815b0dc..dedb7d21 100644 --- a/crates/psrs-backend/src/cc/lower/conversion/tests.rs +++ b/crates/psrs-backend/src/cc/lower/conversion/tests.rs @@ -21,6 +21,7 @@ fn unsupported_typed_boundary_reports_its_source_span() { Type::Constructor(psrs_core::TypeConstructor::Number), ], newtype_ids: Vec::new(), + ordinary_data_ids: Vec::new(), opaque_ids: Vec::new(), callable_types: Vec::new(), constructors: Vec::new(), diff --git a/crates/psrs-backend/src/cc/lower/dictionary/tests.rs b/crates/psrs-backend/src/cc/lower/dictionary/tests.rs index cc49f9e3..ecfd7376 100644 --- a/crates/psrs-backend/src/cc/lower/dictionary/tests.rs +++ b/crates/psrs-backend/src/cc/lower/dictionary/tests.rs @@ -307,6 +307,7 @@ fn dictionary_evidence_module() -> thir::Module { external_types: Vec::new(), types, newtype_ids: Vec::new(), + ordinary_data_ids: Vec::new(), opaque_ids: Vec::new(), callable_types: Vec::new(), constructors: Vec::new(), diff --git a/crates/psrs-backend/src/cc/lower/erased/tests.rs b/crates/psrs-backend/src/cc/lower/erased/tests.rs index a96cfab1..2b106a4d 100644 --- a/crates/psrs-backend/src/cc/lower/erased/tests.rs +++ b/crates/psrs-backend/src/cc/lower/erased/tests.rs @@ -243,6 +243,7 @@ fn module(types: Vec, declaration: Declaration, entry: SymbolId) -> Module external_types: Vec::new(), types, newtype_ids: Vec::new(), + ordinary_data_ids: Vec::new(), opaque_ids: Vec::new(), callable_types: Vec::new(), constructors: Vec::new(), diff --git a/crates/psrs-backend/src/cc/lower/letrec/tests/escaping.rs b/crates/psrs-backend/src/cc/lower/letrec/tests/escaping.rs new file mode 100644 index 00000000..be96a21d --- /dev/null +++ b/crates/psrs-backend/src/cc/lower/letrec/tests/escaping.rs @@ -0,0 +1,124 @@ +use super::*; + +#[test] +fn recursive_function_captures_an_earlier_same_let_value_and_escapes() { + let backend = crate::cc::lower_module(escaping_recursive_module()) + .expect("a closure may escape while capturing a recursive local function"); + let main = backend + .cc + .functions + .iter() + .find(|function| function.name == "main") + .expect("main CC function"); + let recursive = backend + .cc + .functions + .iter() + .find(|function| function.name.starts_with("loop_letrec_")) + .expect("lifted recursive function"); + let escaped = backend + .cc + .functions + .iter() + .find(|function| function.name.starts_with("lambda_")) + .expect("lifted escaping closure"); + + let seed_value = main + .assignments + .iter() + .find_map(|assignment| match assignment.kind { + AssignmentKind::Constant(17) => { + assert_eq!(assignment.span, TextRange::new(22, 23)); + Some(assignment.destination) + } + _ => None, + }) + .expect("main evaluates the same-Let seed before the recursive function"); + let recursive_value = main + .assignments + .iter() + .find_map(|assignment| match &assignment.kind { + AssignmentKind::FunctionRef { + function, captures, .. + } if *function == recursive.symbol => { + assert_eq!(captures, &[seed_value]); + assert_eq!(assignment.span, TextRange::new(30, 92)); + Some(assignment.destination) + } + _ => None, + }) + .expect("main creates the recursive function value"); + let seed_index = main + .assignments + .iter() + .position(|assignment| matches!(assignment.kind, AssignmentKind::Constant(17))) + .expect("seed assignment exists"); + let recursive_index = main + .assignments + .iter() + .position(|assignment| { + matches!( + assignment.kind, + AssignmentKind::FunctionRef { function, .. } if function == recursive.symbol + ) + }) + .expect("recursive function reference exists"); + assert!(seed_index < recursive_index); + let escaping_reference = main + .assignments + .iter() + .find(|assignment| { + matches!( + assignment.kind, + AssignmentKind::FunctionRef { function, .. } if function == escaped.symbol + ) + }) + .expect("main creates the escaping closure"); + let AssignmentKind::FunctionRef { captures, .. } = &escaping_reference.kind else { + unreachable!("the selected assignment is a function reference"); + }; + assert_eq!(captures, &[recursive_value]); + assert_eq!(escaping_reference.span, TextRange::new(100, 124)); + assert!(escaped.assignments.iter().any(|assignment| { + matches!( + assignment.kind, + AssignmentKind::ClosureGetCapture { index: 0, .. } + ) + })); + assert!(contains_indirect_call(&escaped.assignments)); + let escaped_capture = escaped + .assignments + .iter() + .find_map(|assignment| match assignment.kind { + AssignmentKind::ClosureGetCapture { index: 0, .. } => Some(assignment.destination), + _ => None, + }) + .expect("escaping closure restores the captured recursive function"); + assert_eq!( + value_shape(escaped, escaped_capture), + value_shape(main, recursive_value) + ); + + let captured_seed = recursive + .assignments + .iter() + .find_map(|assignment| match assignment.kind { + AssignmentKind::ClosureGetCapture { index: 0, .. } => Some(assignment.destination), + _ => None, + }) + .expect("recursive closure restores its same-Let seed capture"); + assert!(recursive.assignments.iter().any(|assignment| { + matches!( + &assignment.kind, + AssignmentKind::FunctionRef { function, captures, .. } + if *function == recursive.symbol && captures == &[captured_seed] + ) && assignment.span == TextRange::new(30, 92) + })); + assert_eq!(recursive.span, TextRange::new(40, 90)); + assert_eq!( + value_shape(recursive, captured_seed), + Some(ValueShape::Integer) + ); + crate::mir::lower_module(backend.cc.clone()) + .expect("P9 should accept the escaping recursive closure module"); +} diff --git a/crates/psrs-backend/src/cc/lower/letrec/tests/mod.rs b/crates/psrs-backend/src/cc/lower/letrec/tests/mod.rs index bfee48b0..1b37e984 100644 --- a/crates/psrs-backend/src/cc/lower/letrec/tests/mod.rs +++ b/crates/psrs-backend/src/cc/lower/letrec/tests/mod.rs @@ -40,129 +40,6 @@ fn mutually_recursive_local_functions_lower_to_explicit_closures() { .expect("P9 should accept the recursive closure-converted CC module"); } -#[test] -fn recursive_function_captures_an_earlier_same_let_value_and_escapes() { - let backend = crate::cc::lower_module(escaping_recursive_module()) - .expect("a closure may escape while capturing a recursive local function"); - let main = backend - .cc - .functions - .iter() - .find(|function| function.name == "main") - .expect("main CC function"); - let recursive = backend - .cc - .functions - .iter() - .find(|function| function.name.starts_with("loop_letrec_")) - .expect("lifted recursive function"); - let escaped = backend - .cc - .functions - .iter() - .find(|function| function.name.starts_with("lambda_")) - .expect("lifted escaping closure"); - - let seed_value = main - .assignments - .iter() - .find_map(|assignment| match assignment.kind { - AssignmentKind::Constant(17) => { - assert_eq!(assignment.span, TextRange::new(22, 23)); - Some(assignment.destination) - } - _ => None, - }) - .expect("main evaluates the same-Let seed before the recursive function"); - let recursive_value = main - .assignments - .iter() - .find_map(|assignment| match &assignment.kind { - AssignmentKind::FunctionRef { - function, captures, .. - } if *function == recursive.symbol => { - assert_eq!(captures, &[seed_value]); - assert_eq!(assignment.span, TextRange::new(30, 92)); - Some(assignment.destination) - } - _ => None, - }) - .expect("main creates the recursive function value"); - let seed_index = main - .assignments - .iter() - .position(|assignment| matches!(assignment.kind, AssignmentKind::Constant(17))) - .expect("seed assignment exists"); - let recursive_index = main - .assignments - .iter() - .position(|assignment| { - matches!( - assignment.kind, - AssignmentKind::FunctionRef { function, .. } if function == recursive.symbol - ) - }) - .expect("recursive function reference exists"); - assert!(seed_index < recursive_index); - let escaping_reference = main - .assignments - .iter() - .find(|assignment| { - matches!( - assignment.kind, - AssignmentKind::FunctionRef { function, .. } if function == escaped.symbol - ) - }) - .expect("main creates the escaping closure"); - let AssignmentKind::FunctionRef { captures, .. } = &escaping_reference.kind else { - unreachable!("the selected assignment is a function reference"); - }; - assert_eq!(captures, &[recursive_value]); - assert_eq!(escaping_reference.span, TextRange::new(100, 124)); - assert!(escaped.assignments.iter().any(|assignment| { - matches!( - assignment.kind, - AssignmentKind::ClosureGetCapture { index: 0, .. } - ) - })); - assert!(contains_indirect_call(&escaped.assignments)); - let escaped_capture = escaped - .assignments - .iter() - .find_map(|assignment| match assignment.kind { - AssignmentKind::ClosureGetCapture { index: 0, .. } => Some(assignment.destination), - _ => None, - }) - .expect("escaping closure restores the captured recursive function"); - assert_eq!( - value_shape(escaped, escaped_capture), - value_shape(main, recursive_value) - ); - - let captured_seed = recursive - .assignments - .iter() - .find_map(|assignment| match assignment.kind { - AssignmentKind::ClosureGetCapture { index: 0, .. } => Some(assignment.destination), - _ => None, - }) - .expect("recursive closure restores its same-Let seed capture"); - assert!(recursive.assignments.iter().any(|assignment| { - matches!( - &assignment.kind, - AssignmentKind::FunctionRef { function, captures, .. } - if *function == recursive.symbol && captures == &[captured_seed] - ) && assignment.span == TextRange::new(30, 92) - })); - assert_eq!(recursive.span, TextRange::new(40, 90)); - assert_eq!( - value_shape(recursive, captured_seed), - Some(ValueShape::Integer) - ); - crate::mir::lower_module(backend.cc.clone()) - .expect("P9 should accept the escaping recursive closure module"); -} - fn mutual_recursion_module() -> Module { let int = TypeId(0); let boolean = TypeId(1); @@ -456,6 +333,7 @@ fn module(types: Vec, declaration: Declaration, entry: SymbolId) -> Module external_types: Vec::new(), types, newtype_ids: Vec::new(), + ordinary_data_ids: Vec::new(), opaque_ids: Vec::new(), callable_types: Vec::new(), constructors: Vec::new(), @@ -498,3 +376,5 @@ fn value_shape(function: &Function, value: ValueId) -> Option { } mod initialization; + +mod escaping; diff --git a/crates/psrs-backend/src/cc/lower/record/mod.rs b/crates/psrs-backend/src/cc/lower/record/mod.rs index 3056f0f9..7dded157 100644 --- a/crates/psrs-backend/src/cc/lower/record/mod.rs +++ b/crates/psrs-backend/src/cc/lower/record/mod.rs @@ -5,6 +5,7 @@ use super::FunctionLowerer; use crate::BackendError; use psrs_core::{Expr, dictionary::ClassLayout}; +mod open; #[cfg(test)] mod tests; @@ -115,6 +116,13 @@ impl FunctionLowerer<'_> { "record update has no representation requirement", )]); }; + if self + .representations + .record_storage() + .is_some_and(|storage| storage.representation() == representation) + { + return self.lower_open_record_update(expression, record, fields, ty, assignments); + } let layout = ClassLayout::from_record_type(self.module, record.ty).map_err(|message| { vec![BackendError::new( "P8 closure conversion", @@ -228,6 +236,13 @@ impl FunctionLowerer<'_> { "record field access has no representation requirement", )]); }; + if self + .representations + .record_storage() + .is_some_and(|storage| storage.representation() == representation) + { + return self.lower_open_record_field(expression, record, field, ty, assignments); + } let layout = ClassLayout::from_record_type(self.module, record.ty).map_err(|message| { vec![BackendError::new( "P8 closure conversion", diff --git a/crates/psrs-backend/src/cc/lower/record/open.rs b/crates/psrs-backend/src/cc/lower/record/open.rs new file mode 100644 index 00000000..4d0decf4 --- /dev/null +++ b/crates/psrs-backend/src/cc/lower/record/open.rs @@ -0,0 +1,168 @@ +use super::*; +use crate::cc::RecordOperation; + +impl FunctionLowerer<'_> { + pub(super) fn lower_open_record_field( + &mut self, + expression: &Expr, + record: &Expr, + field: &str, + target: ValueShape, + assignments: &mut Vec, + ) -> Result> { + if self.module.record_field(record.ty, field).is_none() { + return Err(record_error( + expression.span, + "open record field is absent from the checked prefix", + )); + } + let record = self.lower_value(record, assignments)?; + self.project_labelled_field(record, field, target, expression.span, assignments) + } + + /// Find one known field by its literal label and read the erased slot, + /// then recover the requested payload through the common plan. + /// + /// This is the kernel behind open-row field access and record patterns. + /// Both consumers are checked against the record type's prefix; the literal + /// label keys the runtime find, so no canonical index is assumed at the + /// labelled store. + pub(in crate::cc) fn project_labelled_field( + &mut self, + record: ValueId, + field: &str, + target: ValueShape, + span: psrs_span::TextRange, + assignments: &mut Vec, + ) -> Result> { + let label = self.lower_literal( + AssignmentKind::StringConstant(field.into()), + span, + ValueShape::String, + assignments, + ); + let index = self.fresh(ValueShape::Integer); + assignments.push(Assignment { + destination: index, + kind: AssignmentKind::Record { + destination: index, + record, + operation: RecordOperation::Find { label }, + }, + span, + }); + let erased = crate::cc::payload::erased_shape(); + let value = self.fresh(erased); + assignments.push(Assignment { + destination: value, + kind: AssignmentKind::Record { + destination: value, + record, + operation: RecordOperation::Read { index }, + }, + span, + }); + let plan = self.recover_payload(target, span)?; + Ok(self.emit_conversion(value, erased, target, plan, span, assignments)) + } + + /// Replace labelled slots in one fresh store. The base and every update + /// expression lower exactly once, in source order; each replacement + /// payload erases through the common planner into a private slot keyed by + /// its literal label, and the kernel keeps the store's complete + /// label/value sequence, including the unknown tail. + pub(super) fn lower_open_record_update( + &mut self, + expression: &Expr, + record: &Expr, + fields: &[(String, Expr)], + ty: ValueShape, + assignments: &mut Vec, + ) -> Result> { + let Some(_) = self.representations.record_storage() else { + return Err(record_error( + expression.span, + "open record update has no storage schema", + )); + }; + let mut labels = Vec::with_capacity(fields.len()); + for (label, _) in fields { + if self.module.record_field(record.ty, label).is_none() { + return Err(record_error( + expression.span, + "open record update field is absent from the checked prefix", + )); + } + if labels.contains(label) { + return Err(record_error( + expression.span, + "open record update contains a duplicate field", + )); + } + labels.push(label.clone()); + } + // A local keeps the layout its binder established; other producers + // convert into the store at this checked use boundary. + let source_type = match &record.kind { + psrs_core::ExprKind::Local(local) => self.local_types.get(local).copied(), + _ => None, + } + .unwrap_or(record.ty); + let record_type = record.ty; + let base = self.lower_value(record, assignments)?; + let base_shape = self.value_shape(source_type, expression.span)?; + let expected = self.value_shape(record_type, expression.span)?; + let base = if base_shape == expected { + base + } else { + let plan = self.typed_conversion( + source_type, + record_type, + base_shape, + expected, + expression.span, + )?; + self.emit_conversion( + base, + base_shape, + expected, + plan, + expression.span, + assignments, + ) + }; + let erased = crate::cc::payload::erased_shape(); + let mut replacements = Vec::with_capacity(fields.len()); + for (label, value) in fields { + let label_id = self.lower_literal( + AssignmentKind::StringConstant(label.clone()), + expression.span, + ValueShape::String, + assignments, + ); + let value_id = self.lower_value(value, assignments)?; + let source_shape = self.value_shape(value.ty, value.span)?; + let plan = self.erase_payload(source_shape, expression.span)?; + let payload = self.emit_conversion( + value_id, + source_shape, + erased, + plan, + expression.span, + assignments, + ); + replacements.push((label_id, payload)); + } + let destination = self.fresh(ty); + assignments.push(Assignment { + destination, + kind: AssignmentKind::Record { + destination, + record: base, + operation: RecordOperation::Update { replacements }, + }, + span: expression.span, + }); + Ok(destination) + } +} diff --git a/crates/psrs-backend/src/cc/lower/record/tests.rs b/crates/psrs-backend/src/cc/lower/record/tests.rs index 80499ab4..64be3d5e 100644 --- a/crates/psrs-backend/src/cc/lower/record/tests.rs +++ b/crates/psrs-backend/src/cc/lower/record/tests.rs @@ -194,6 +194,7 @@ fn generic_record_module(include_read: bool, include_build_update: bool) -> Modu external_types: Vec::new(), types, newtype_ids: Vec::new(), + ordinary_data_ids: Vec::new(), opaque_ids: Vec::new(), callable_types: Vec::new(), constructors: Vec::new(), diff --git a/crates/psrs-backend/src/cc/mod.rs b/crates/psrs-backend/src/cc/mod.rs index c68e1b54..bb430202 100644 --- a/crates/psrs-backend/src/cc/mod.rs +++ b/crates/psrs-backend/src/cc/mod.rs @@ -19,11 +19,15 @@ pub use control::{ TypeIdentity, }; pub(crate) use lower::{Family, Shape, plan_external_targets, plan_shape_for}; +mod assignment; +pub use assignment::{Assignment, AssignmentKind, TagCase}; mod convert; mod layout; mod lower; pub(crate) mod payload; mod projection; +mod record; +pub use record::{RecordOperation, RecordStorage}; mod representation; mod scalar; mod source_abi; @@ -85,188 +89,6 @@ pub struct Function { pub span: TextRange, } -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Assignment { - pub destination: ValueId, - pub kind: AssignmentKind, - pub span: TextRange, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum AssignmentKind { - /// Retains the checked source operation at its explicit P8 erasure boundary. - /// The runtime result is the existing value; Wasm encoding erases this witness. - ControlEvidence { - value: ValueId, - implementation: ValueId, - arguments: Vec, - contract: ControlContractId, - }, - Constant(i32), - NumberConstant(String), - StringConstant(String), - Primitive { - op: BinaryOp, - left: ValueId, - right: ValueId, - }, - Unary { - op: UnaryOp, - value: ValueId, - }, - DirectCall { - /// Required for predeclared machine helpers; absent for ordinary calls. - contract: Option, - function: SymbolId, - arguments: Vec, - }, - FunctionRef { - function: SymbolId, - signature: SignatureId, - captures: Vec, - }, - IndirectCall { - function: ValueId, - signature: SignatureId, - arguments: Vec, - }, - /// A checked source runner supplies and discharges one logical root. - ClosureGetCapture { - closure: ValueId, - index: u32, - }, - RepresentationTest { - destination: ValueId, - value: ValueId, - reference: Reference, - }, - RepresentationCast { - destination: ValueId, - value: ValueId, - reference: Reference, - }, - AggregateConvert { - destination: ValueId, - value: ValueId, - conversion: AggregateConvert, - }, - ProductNew { - destination: ValueId, - representation: ReprId, - arguments: Vec, - }, - ProductGet { - destination: ValueId, - representation: ReprId, - field: u32, - value: ValueId, - }, - VariantNew { - destination: ValueId, - representation: ReprId, - case: u32, - fields: Vec, - }, - VariantTag { - destination: ValueId, - representation: ReprId, - value: ValueId, - }, - VariantGet { - destination: ValueId, - representation: ReprId, - case: u32, - field: u32, - value: ValueId, - }, - ArrayNew { - destination: ValueId, - representation: ReprId, - elements: Vec, - }, - ArrayFill { - destination: ValueId, - representation: ReprId, - length: ValueId, - value: ValueId, - }, - ArrayLen { - destination: ValueId, - value: ValueId, - }, - /// `Array.append`: a fresh array holding `left`'s elements followed by - /// `right`'s. Both operands and the result name the same array - /// representation, and neither operand is mutated. - ArrayAppend { - destination: ValueId, - representation: ReprId, - left: ValueId, - right: ValueId, - }, - /// A source `String`'s canonical UTF-8 bytes as an `Array Int`. A source - /// string is a sequence of Unicode scalar values, so this is lossless. - StringToBytes { - destination: ValueId, - representation: ReprId, - value: ValueId, - }, - /// Artifact-backed operation with checked language arguments before ABI erasure. - RuntimeCall { - intrinsic: psrs_hir::Intrinsic, - arguments: Vec, - }, - /// An `Array Int` read as a source `String`. Every element must be a - /// canonical byte and the bytes must be well-formed UTF-8. - BytesToString { - destination: ValueId, - representation: ReprId, - value: ValueId, - }, - ArrayGet { - destination: ValueId, - representation: ReprId, - value: ValueId, - index: ValueId, - }, - ArrayClone { - destination: ValueId, - representation: ReprId, - value: ValueId, - }, - ArraySet { - destination: ValueId, - representation: ReprId, - value: ValueId, - index: ValueId, - new_value: ValueId, - }, - If { - condition: ValueId, - then_assignments: Vec, - then_value: ValueId, - else_assignments: Vec, - else_value: ValueId, - }, - /// A multi-way choice on a closed, integer-tagged data type. Pattern - /// matching chooses these tags; P9 carries the choice into MIR's Switch. - TagSwitch { - value: ValueId, - cases: Vec, - default_assignments: Vec, - default_value: ValueId, - }, - /// An explicit trap result used by impossible decision-DAG edges. - Unreachable, -} - -/// One selected arm of a tag switch, with its branch-local computations. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct TagCase { - pub tag: i32, - pub assignments: Vec, - pub value: ValueId, -} - /// Lowers Core with the default backend-side external binding extraction. /// Prefer [`lower_module_with_bindings`] when the caller already owns the /// backend input boundary. @@ -300,15 +122,6 @@ pub(crate) fn lower_module_with_relations( ) -> Result> { crate::bindings::lower_primitives(&mut module, source)?; bindings.validate_core(&module)?; - module = psrs_core::instantiate_local_rows(module, source).map_err(|errors| { - errors - .into_iter() - .map(|error| { - BackendError::new("P8 local row instantiation", error.span, error.message) - .with_module(error.module) - }) - .collect::>() - })?; let relations = source.unwrap_or(&module); registry.register_newtypes(relations); if let Err(errors) = module.verify_with_source(relations) { diff --git a/crates/psrs-backend/src/cc/payload.rs b/crates/psrs-backend/src/cc/payload.rs index f10ce210..740ed2ab 100644 --- a/crates/psrs-backend/src/cc/payload.rs +++ b/crates/psrs-backend/src/cc/payload.rs @@ -22,25 +22,25 @@ pub(crate) fn stored_guest_layout( GuestLayout::Scalar { .. } | GuestLayout::Boxed { .. } => GuestLayout::Scalar { shape: erased_shape(), }, - GuestLayout::Product { labels, fields, .. } => GuestLayout::Product { - repr: super::layout::protocols::record(table, labels)?, + GuestLayout::Product { + repr, + labels, + fields, + } => GuestLayout::RecordStorage { + storage: table.record_storage()?, + product: *repr, labels: labels.clone(), - fields: fields - .iter() - .map(|field| Field { - value: field.value.clone(), - stored: erased_shape(), - }) - .collect(), + fields: fields.clone(), }, - GuestLayout::Array { repr, element } => { - if !matches!(table.representation(*repr), Some(super::Representation::Array { element }) if *element == erased_shape()) - { - return None; - } + GuestLayout::RecordStorage { .. } => field.value.clone(), + GuestLayout::Array { element, .. } => { + let repr = super::layout::protocols::array_for(table, erased_shape())?; GuestLayout::Array { - repr: *repr, - element: element.clone(), + repr, + element: Box::new(Field { + value: element.value.clone(), + stored: erased_shape(), + }), } } GuestLayout::Variant { .. } => field.value.clone(), @@ -226,18 +226,13 @@ pub(crate) trait PayloadPlanner { return Err(self.payload_error(span, "record owner labels do not name a product")); }; let fields = fields.clone(); - let protocol = super::layout::protocols::record(self.payload_table(), &labels) - .ok_or_else(|| self.payload_error(span, "record owner has no erased field protocol"))?; - if concrete == protocol { - return Ok(Some(if entering { - ValueConversion::EraseReference - } else { - ValueConversion::RecoverReference { - destination: shape, - evidence: RecoveryEvidence::TypeInstantiation, - } - })); - } + let protocol = self + .payload_table() + .record_storage() + .ok_or_else(|| { + self.payload_error(span, "record owner has no labelled storage protocol") + })? + .representation(); let plans = fields .into_iter() .map(|field| { @@ -250,7 +245,7 @@ pub(crate) trait PayloadPlanner { .collect::, _>>()?; Ok(Some(if entering { sequence(vec![ - ValueConversion::ProductMap { + ValueConversion::RecordPack { source: concrete, target: protocol, labels, @@ -268,7 +263,7 @@ pub(crate) trait PayloadPlanner { destination: protocol_shape, evidence: RecoveryEvidence::TypeInstantiation, }, - ValueConversion::ProductMap { + ValueConversion::RecordUnpack { source: protocol, target: concrete, labels, diff --git a/crates/psrs-backend/src/cc/projection.rs b/crates/psrs-backend/src/cc/projection.rs index 919c3a66..d4d6ca11 100644 --- a/crates/psrs-backend/src/cc/projection.rs +++ b/crates/psrs-backend/src/cc/projection.rs @@ -382,6 +382,7 @@ mod tests { external_types: Vec::new(), types, newtype_ids: Vec::new(), + ordinary_data_ids: Vec::new(), opaque_ids: Vec::new(), callable_types: Vec::new(), type_names: vec![(maybe, "Data.Maybe.Maybe".into())], diff --git a/crates/psrs-backend/src/cc/record/mod.rs b/crates/psrs-backend/src/cc/record/mod.rs new file mode 100644 index 00000000..330825f1 --- /dev/null +++ b/crates/psrs-backend/src/cc/record/mod.rs @@ -0,0 +1,204 @@ +//! The record owner's immutable physical storage schema. +//! +//! Schema verification checks representation references and element shapes. +//! Equal lengths and unique sorted runtime labels are constructor obligations, +//! not facts that a representation-table check can prove about arbitrary arrays. + +use super::{RefShape, Reference, ReprId, Representation, RepresentationTable, ValueShape}; + +/// Structural reads from the complete labelled store. Result payload recovery +/// remains an explicit checked conversion at the use site. +/// +/// [`RecordOperation::Update`] replaces existing labelled slots in one fresh +/// store: every replacement label must exist in the runtime store (established +/// by construction and checked at runtime), the complete label/value sequence +/// otherwise stays the owner's single authoritative sequence, and the input +/// store is never mutated. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum RecordOperation { + Length, + LabelAt { + index: super::ValueId, + }, + Find { + label: super::ValueId, + }, + Read { + index: super::ValueId, + }, + Update { + replacements: Vec<(super::ValueId, super::ValueId)>, + }, + /// Checked source build from parallel arrays. MIR copies both arrays and + /// validates their lengths and label order before publishing the header. + Build { + labels: super::ValueId, + values: super::ValueId, + }, +} + +impl RecordOperation { + /// Ordered operands with their physical shapes. Update pairs flatten to + /// the alternating label/value order used by every consumer. + pub(crate) fn arguments(&self, storage: RecordStorage) -> Vec<(super::ValueId, ValueShape)> { + match self { + Self::Length => Vec::new(), + Self::LabelAt { index } | Self::Read { index } => { + vec![(*index, ValueShape::Integer)] + } + Self::Find { label } => vec![(*label, ValueShape::String)], + Self::Update { replacements } => replacements + .iter() + .flat_map(|(label, value)| { + [(*label, ValueShape::String), (*value, erased_argument())] + }) + .collect(), + Self::Build { labels, values } => vec![ + // Source arrays erase both element types. The implementation + // recovers labels as String before copying them into the + // owner's specialized label array. + (*labels, reference(storage.values())), + (*values, reference(storage.values())), + ], + } + } + + pub(crate) fn result_shape(&self, storage: RecordStorage) -> ValueShape { + match self { + Self::Length | Self::Find { .. } => ValueShape::Integer, + Self::LabelAt { .. } => ValueShape::String, + Self::Read { .. } => super::payload::erased_shape(), + Self::Update { .. } | Self::Build { .. } => ValueShape::Reference(Reference { + nullable: false, + heap: RefShape::Repr(storage.representation()), + }), + } + } + + /// Rebind the ordered operands positionally. Helper interning uses this to + /// abstract concrete call-site ids into one shared helper per arity. + pub(crate) fn with_arguments(self, arguments: Vec) -> Self { + let mut remaining = arguments.into_iter(); + let mut take = |message| remaining.next().expect(message); + match self { + Self::Length => Self::Length, + Self::LabelAt { .. } => Self::LabelAt { + index: take("one argument"), + }, + Self::Read { .. } => Self::Read { + index: take("one argument"), + }, + Self::Find { .. } => Self::Find { + label: take("one label"), + }, + Self::Update { replacements } => Self::Update { + replacements: replacements + .into_iter() + .map(|_| (take("one label"), take("one value"))) + .collect(), + }, + Self::Build { .. } => Self::Build { + labels: take("label array"), + values: take("payload array"), + }, + } + } +} + +fn erased_argument() -> ValueShape { + super::payload::erased_shape() +} + +#[cfg(test)] +mod tests; + +#[derive(Clone, Debug, PartialEq, Eq, Default)] +pub(in crate::cc) struct RecordPlanning { + storage: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RecordStorage { + representation: ReprId, + labels: ReprId, + values: ReprId, +} + +impl RecordStorage { + pub fn representation(self) -> ReprId { + self.representation + } + + pub fn labels(self) -> ReprId { + self.labels + } + + pub fn values(self) -> ReprId { + self.values + } + + fn verify(self, table: &RepresentationTable) -> Result<(), &'static str> { + if table.representation(self.labels()) + != Some(&Representation::Array { + element: ValueShape::String, + }) + || table.representation(self.values()) + != Some(&Representation::Array { + element: super::payload::erased_shape(), + }) + { + return Err("record storage arrays have invalid label or erased-value elements"); + } + if table.representation(self.representation()) + != Some(&Representation::Product { + fields: vec![reference(self.labels()), reference(self.values())], + }) + || table.product_labels(self.representation()).is_some() + { + return Err("record storage header does not match its registered array schema"); + } + Ok(()) + } +} + +impl RepresentationTable { + pub(crate) fn ensure_record_storage(&mut self) -> RecordStorage { + if let Some(storage) = self.record.storage { + return storage; + } + let labels = super::layout::protocols::array(self, ValueShape::String); + let values = super::layout::protocols::array(self, super::payload::erased_shape()); + let representation = self.reserve(); + self.set( + representation, + Representation::Product { + fields: vec![reference(labels), reference(values)], + }, + ); + let storage = RecordStorage { + representation, + labels, + values, + }; + self.record.storage = Some(storage); + storage + } + + pub(crate) fn record_storage(&self) -> Option { + self.record.storage + } + + pub(crate) fn verify_record_storage(&self) -> Result<(), &'static str> { + if let Some(storage) = self.record_storage() { + storage.verify(self)?; + } + Ok(()) + } +} + +fn reference(id: ReprId) -> ValueShape { + ValueShape::Reference(Reference { + nullable: false, + heap: RefShape::Repr(id), + }) +} diff --git a/crates/psrs-backend/src/cc/record/tests.rs b/crates/psrs-backend/src/cc/record/tests.rs new file mode 100644 index 00000000..d8c2bf6a --- /dev/null +++ b/crates/psrs-backend/src/cc/record/tests.rs @@ -0,0 +1,109 @@ +use super::*; + +fn verify(table: RepresentationTable) -> Result<(), Vec> { + super::super::verify_module(&super::super::Module { + name: "RecordSchema".into(), + externals: Vec::new(), + representations: table, + functions: Vec::new(), + entry: None, + span: psrs_span::TextRange::new(0, 1), + }) +} + +#[test] +fn a_record_schema_keeps_its_identity_and_reuses_the_array_owner() { + let mut table = RepresentationTable::default(); + let existing_values = + super::super::layout::protocols::array(&mut table, super::super::payload::erased_shape()); + let first = table.ensure_record_storage(); + assert_eq!(first.values(), existing_values); + let size = table.representations.len(); + assert_eq!(table.ensure_record_storage(), first); + assert_eq!(table.representations.len(), size); + verify(table).expect("registered schema passes the public CC verifier"); +} + +#[test] +fn malformed_record_schemas_fail_at_the_public_cc_boundary() { + let mut baseline = RepresentationTable::default(); + let schema = baseline.ensure_record_storage(); + let mutations: &[fn(&mut RepresentationTable, RecordStorage)] = &[ + |table, schema| { + table.set( + schema.labels(), + Representation::Array { + element: ValueShape::Integer, + }, + ) + }, + |table, schema| { + table.set( + schema.values(), + Representation::Array { + element: ValueShape::String, + }, + ) + }, + |table, _| table.record.storage.as_mut().unwrap().labels = ReprId(u32::MAX), + |table, schema| { + table.set( + schema.representation(), + Representation::Product { + fields: vec![reference(schema.values()), reference(schema.labels())], + }, + ) + }, + |table, schema| { + table.set( + schema.representation(), + Representation::Product { + fields: vec![reference(schema.labels())], + }, + ) + }, + |table, schema| { + table.set( + schema.representation(), + Representation::Product { + fields: vec![ + ValueShape::Reference(Reference { + nullable: true, + heap: RefShape::Repr(schema.labels()), + }), + reference(schema.values()), + ], + }, + ) + }, + |table, schema| { + table.set_product_labels( + schema.representation(), + vec!["labels".into(), "values".into()], + ) + }, + |table, schema| { + table.set( + schema.representation(), + Representation::Array { + element: reference(schema.labels()), + }, + ) + }, + ]; + for (index, mutate) in mutations.iter().enumerate() { + let mut table = baseline.clone(); + mutate(&mut table, schema); + // Registration must not replace corrupted published metadata with an + // apparently valid fresh schema and hide the malformed owner contract. + let published = table.record_storage().unwrap(); + assert_eq!(table.ensure_record_storage(), published); + let errors = verify(table).expect_err("malformed record schema must be rejected"); + assert!( + errors + .iter() + .any(|error| error.message.contains("record storage")), + "mutation {index}: {errors:?}" + ); + } +} diff --git a/crates/psrs-backend/src/cc/representation.rs b/crates/psrs-backend/src/cc/representation.rs index c7644e2d..6d7f39b0 100644 --- a/crates/psrs-backend/src/cc/representation.rs +++ b/crates/psrs-backend/src/cc/representation.rs @@ -117,6 +117,14 @@ pub enum GuestLayout { labels: Vec, fields: Vec, }, + /// Logical closed fields projected from the owner's labelled erased store. + /// The header's two physical fields are not these logical source fields. + RecordStorage { + storage: super::RecordStorage, + product: ReprId, + labels: Vec, + fields: Vec, + }, /// A tagged value with its representation and cases in tag order. Variant { repr: ReprId, cases: Vec }, /// A GC array with its representation and projected element. @@ -127,6 +135,10 @@ impl GuestLayout { /// The abstract shape of this layout, for the erased aggregate protocol. pub fn shape(&self) -> ValueShape { match self { + GuestLayout::RecordStorage { storage, .. } => ValueShape::Reference(Reference { + nullable: false, + heap: RefShape::Repr(storage.representation()), + }), GuestLayout::Scalar { shape } | GuestLayout::Boxed { shape } => *shape, GuestLayout::Product { repr, .. } | GuestLayout::Variant { repr, .. } @@ -227,6 +239,8 @@ fn storage_case( pub struct RepresentationTable { /// Source planning publishes immutable machine metadata before CC lowering. pub(in crate::cc) control: super::control::ControlPlanning, + /// The record owner publishes the schema; consumers use its checked handles. + pub(in crate::cc) record: super::record::RecordPlanning, pub representations: Vec, pub signatures: Vec, /// Canonical logical labels for record products; positional products do diff --git a/crates/psrs-backend/src/cc/source_abi.rs b/crates/psrs-backend/src/cc/source_abi.rs index cf8ec77b..35f093e0 100644 --- a/crates/psrs-backend/src/cc/source_abi.rs +++ b/crates/psrs-backend/src/cc/source_abi.rs @@ -97,7 +97,16 @@ fn user_payload_shape( ) -> Option { let head = head_user_type(module, id)?; let constructors = constructors_of(module, head); - if constructors.is_empty() || constructors.iter().all(|case| case.field_count == 0) { + if module.opaque_ids.contains(&head) { + return Some(ValueShape::Integer); + } + if !module.ordinary_data_ids.contains(&head) { + return None; + } + if constructors.is_empty() { + return Some(super::payload::erased_shape()); + } + if constructors.iter().all(|case| case.field_count == 0) { return Some(ValueShape::Integer); } Some(reference(*type_reprs.get(&head)?)) diff --git a/crates/psrs-backend/src/cc/verify/ops/aggregate/functions.rs b/crates/psrs-backend/src/cc/verify/ops/aggregate/functions.rs index 9280b7d5..bb58635a 100644 --- a/crates/psrs-backend/src/cc/verify/ops/aggregate/functions.rs +++ b/crates/psrs-backend/src/cc/verify/ops/aggregate/functions.rs @@ -34,7 +34,9 @@ pub(in crate::cc::verify::ops) fn verify_adapter_functions( ValueConversion::ArrayMap { element, .. } => { verify_adapter_functions(assignment, element, signatures)?; } - ValueConversion::ProductMap { fields, .. } => { + ValueConversion::ProductMap { fields, .. } + | ValueConversion::RecordPack { fields, .. } + | ValueConversion::RecordUnpack { fields, .. } => { for field in fields { verify_adapter_functions(assignment, field, signatures)?; } diff --git a/crates/psrs-backend/src/cc/verify/ops/aggregate/mod.rs b/crates/psrs-backend/src/cc/verify/ops/aggregate/mod.rs index 6b38f07a..311d4ee7 100644 --- a/crates/psrs-backend/src/cc/verify/ops/aggregate/mod.rs +++ b/crates/psrs-backend/src/cc/verify/ops/aggregate/mod.rs @@ -243,6 +243,9 @@ fn verify_plan( } Ok(plan.output_shape(source)) } + ValueConversion::RecordPack { .. } | ValueConversion::RecordUnpack { .. } => { + record::verify_record_plan(assignment, plan, source, table) + } ValueConversion::ProductMap { source: source_id, target, @@ -305,5 +308,6 @@ fn erased_reference() -> Reference { } } +mod record; #[cfg(test)] mod tests; diff --git a/crates/psrs-backend/src/cc/verify/ops/aggregate/record.rs b/crates/psrs-backend/src/cc/verify/ops/aggregate/record.rs new file mode 100644 index 00000000..a79845e7 --- /dev/null +++ b/crates/psrs-backend/src/cc/verify/ops/aggregate/record.rs @@ -0,0 +1,61 @@ +use super::*; + +pub(super) fn verify_record_plan( + assignment: &Assignment, + plan: &ValueConversion, + source_shape: ValueShape, + table: &RepresentationTable, +) -> Result> { + let (source, target, labels, fields, packing) = match plan { + ValueConversion::RecordPack { + source, + target, + labels, + fields, + } => (*source, *target, labels, fields, true), + ValueConversion::RecordUnpack { + source, + target, + labels, + fields, + } => (*source, *target, labels, fields, false), + _ => unreachable!("record verifier only accepts record conversion plans"), + }; + let fail = |message| assignment_error(assignment, message); + table.verify_record_storage().map_err(fail)?; + let storage = table + .record_storage() + .ok_or_else(|| fail("record conversion has no registered storage schema"))?; + let product = if packing { source } else { target }; + let store = if packing { target } else { source }; + let Some(Representation::Product { + fields: product_fields, + }) = table.representation(product) + else { + return Err(fail("record conversion has no closed product layout")); + }; + if store != storage.representation() + || source_shape != repr_shape(source) + || table.product_labels(product) != Some(labels.as_slice()) + || fields.len() != product_fields.len() + || fields.len() != labels.len() + || labels.windows(2).any(|pair| pair[0] >= pair[1]) + { + return Err(fail( + "record conversion endpoints, labels or arity are invalid", + )); + } + for (plan, product_field) in fields.iter().zip(product_fields) { + let (input, output) = if packing { + (*product_field, erased_shape()) + } else { + (erased_shape(), *product_field) + }; + if super::verify_plan(assignment, plan, input, table)? != output { + return Err(fail( + "record field conversion does not match its stored payload policy", + )); + } + } + Ok(repr_shape(target)) +} diff --git a/crates/psrs-backend/src/cc/verify/ops/mod.rs b/crates/psrs-backend/src/cc/verify/ops/mod.rs index 1ae4dfae..553aac25 100644 --- a/crates/psrs-backend/src/cc/verify/ops/mod.rs +++ b/crates/psrs-backend/src/cc/verify/ops/mod.rs @@ -16,6 +16,8 @@ use tag_switch::verify_tag_switch; mod aggregate; mod arrays; mod control; +mod products; +mod record; mod string_bytes; mod table; mod tag_switch; @@ -302,70 +304,20 @@ pub(super) fn verify_assignments( } uses.extend(arguments.iter().copied()); } - AssignmentKind::ProductGet { - destination, - representation, - field, - value, - } => { - verify_embedded_destination(assignment, *destination)?; - let representation_id = *representation; - let Some(representation) = table.representation(representation_id) else { - return Err(assignment_error( - assignment, - "unknown representation handle", - )); - }; - let (expected, source, boxed) = match representation { - Representation::Box { value: expected } if *field == 0 => { - (*expected, Some(repr_shape(representation_id)), true) - } - Representation::Product { fields } => { - let Some(expected) = fields.get(*field as usize).copied() else { - return Err(assignment_error( - assignment, - "product projection field is out of range", - )); - }; - (expected, None, false) - } - Representation::Box { .. } => { - return Err(assignment_error( - assignment, - "box projection field is out of range", - )); - } - _ => { - return Err(assignment_error( - assignment, - "product projection requires a product or box representation", - )); - } - }; - if let Some(source) = source { - require_value_shape(declared, *value, source, assignment)?; - } else { - verify_product_value(declared, *value, representation_id, assignment)?; - } - // The shared i32 box stores an `Integer` field but is also the - // erased home of `Boolean`. Their checks are performed - // explicitly by the boxing/unboxing path, so a projection may - // declare either i32-family shape. - let destination_shape = declared.get(&assignment.destination).copied(); - let result_ok = match (boxed, expected) { - (true, ValueShape::Integer) => matches!( - destination_shape, - Some(ValueShape::Integer | ValueShape::Boolean) - ), - _ => destination_shape == Some(expected), - }; - if !result_ok { + AssignmentKind::ProductGet { .. } => { + uses = products::verify_get(assignment, declared, table)?; + } + AssignmentKind::Record { .. } => { + uses = record::verify(assignment, declared, table)?; + } + AssignmentKind::RecordPrimitive { provider, .. } => { + if !signatures.contains_key(provider) { return Err(assignment_error( assignment, - "product projection has an incompatible result shape", + "record primitive references an unknown provider symbol", )); } - uses.push(*value); + uses = record::verify_primitive(assignment, declared, table)?; } AssignmentKind::VariantNew { .. } | AssignmentKind::VariantTag { .. } diff --git a/crates/psrs-backend/src/cc/verify/ops/products.rs b/crates/psrs-backend/src/cc/verify/ops/products.rs new file mode 100644 index 00000000..39555fca --- /dev/null +++ b/crates/psrs-backend/src/cc/verify/ops/products.rs @@ -0,0 +1,75 @@ +use super::*; + +pub(super) fn verify_get( + assignment: &Assignment, + declared: &HashMap, + table: &RepresentationTable, +) -> Result, Vec> { + let AssignmentKind::ProductGet { + destination, + representation, + field, + value, + } = &assignment.kind + else { + unreachable!("product projection verifier dispatch"); + }; + verify_embedded_destination(assignment, *destination)?; + let representation_id = *representation; + let Some(representation) = table.representation(representation_id) else { + return Err(assignment_error( + assignment, + "unknown representation handle", + )); + }; + let (expected, source, boxed) = match representation { + Representation::Box { value: expected } if *field == 0 => { + (*expected, Some(repr_shape(representation_id)), true) + } + Representation::Product { fields } => { + let Some(expected) = fields.get(*field as usize).copied() else { + return Err(assignment_error( + assignment, + "product projection field is out of range", + )); + }; + (expected, None, false) + } + Representation::Box { .. } => { + return Err(assignment_error( + assignment, + "box projection field is out of range", + )); + } + _ => { + return Err(assignment_error( + assignment, + "product projection requires a product or box representation", + )); + } + }; + if let Some(source) = source { + require_value_shape(declared, *value, source, assignment)?; + } else { + verify_product_value(declared, *value, representation_id, assignment)?; + } + // The shared i32 box stores an `Integer` field but is also the + // erased home of `Boolean`. Their checks are performed + // explicitly by the boxing/unboxing path, so a projection may + // declare either i32-family shape. + let destination_shape = declared.get(&assignment.destination).copied(); + let result_ok = match (boxed, expected) { + (true, ValueShape::Integer) => matches!( + destination_shape, + Some(ValueShape::Integer | ValueShape::Boolean) + ), + _ => destination_shape == Some(expected), + }; + if !result_ok { + return Err(assignment_error( + assignment, + "product projection has an incompatible result shape", + )); + } + Ok(vec![*value]) +} diff --git a/crates/psrs-backend/src/cc/verify/ops/record.rs b/crates/psrs-backend/src/cc/verify/ops/record.rs new file mode 100644 index 00000000..4d58f4ae --- /dev/null +++ b/crates/psrs-backend/src/cc/verify/ops/record.rs @@ -0,0 +1,249 @@ +use super::*; +use crate::cc::RecordOperation; + +pub(super) fn verify( + assignment: &Assignment, + declared: &HashMap, + table: &RepresentationTable, +) -> Result, Vec> { + let AssignmentKind::Record { + destination, + record, + operation, + } = &assignment.kind + else { + unreachable!("record verifier dispatch"); + }; + if matches!(operation, RecordOperation::Build { .. }) { + return Err(assignment_error( + assignment, + "record build must retain its checked provider binding", + )); + } + verify_embedded_destination(assignment, *destination)?; + let storage = table.record_storage().ok_or_else(|| { + assignment_error( + assignment, + "record operation has no registered storage schema", + ) + })?; + require_value_shape( + declared, + *record, + repr_shape(storage.representation()), + assignment, + )?; + let mut uses = vec![*record]; + for (value, shape) in operation.arguments(storage) { + require_value_shape(declared, value, shape, assignment)?; + uses.push(value); + } + require_value_shape( + declared, + *destination, + operation.result_shape(storage), + assignment, + )?; + Ok(uses) +} + +pub(super) fn verify_primitive( + assignment: &Assignment, + declared: &HashMap, + table: &RepresentationTable, +) -> Result, Vec> { + let AssignmentKind::RecordPrimitive { + record, operation, .. + } = &assignment.kind + else { + unreachable!("record primitive verifier dispatch"); + }; + verify_embedded_destination(assignment, assignment.destination)?; + let storage = table.record_storage().ok_or_else(|| { + assignment_error( + assignment, + "record primitive has no registered storage schema", + ) + })?; + let build = matches!(operation, RecordOperation::Build { .. }); + if build != record.is_none() { + return Err(assignment_error( + assignment, + "record primitive operation has an invalid record operand", + )); + } + let mut uses = Vec::new(); + if let Some(record) = record { + require_value_shape( + declared, + *record, + repr_shape(storage.representation()), + assignment, + )?; + uses.push(*record); + } + for (value, shape) in operation.arguments(storage) { + require_value_shape(declared, value, shape, assignment)?; + uses.push(value); + } + require_value_shape( + declared, + assignment.destination, + operation.result_shape(storage), + assignment, + )?; + Ok(uses) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_build_without_checked_provider_evidence() { + let destination = ValueId(3); + let assignment = Assignment { + destination, + kind: AssignmentKind::Record { + destination, + record: ValueId(0), + operation: RecordOperation::Build { + labels: ValueId(1), + values: ValueId(2), + }, + }, + span: psrs_span::TextRange::new(0, 1), + }; + let errors = verify( + &assignment, + &HashMap::new(), + &RepresentationTable::default(), + ) + .expect_err("record build must retain its checked provider identity"); + assert!(errors.iter().any(|error| { + error + .message + .contains("record build must retain its checked provider binding") + })); + } + + #[test] + fn every_record_read_and_update_rejects_forged_operands_at_the_public_boundary() { + use crate::BackendErrorKind; + use crate::cc::{Function, Module, ValueDecl}; + use psrs_hir::ModuleId; + + let mut table = RepresentationTable::default(); + let storage = table.ensure_record_storage(); + let header = repr_shape(storage.representation()); + let erased = crate::cc::payload::erased_shape(); + // These are the contract's independently specified operands/results, + // not obtained from RecordOperation::arguments or result_shape. + let cases = [ + (RecordOperation::Length, ValueShape::Integer, vec![]), + ( + RecordOperation::LabelAt { index: ValueId(1) }, + ValueShape::String, + vec![1], + ), + ( + RecordOperation::Find { label: ValueId(2) }, + ValueShape::Integer, + vec![2], + ), + (RecordOperation::Read { index: ValueId(1) }, erased, vec![1]), + ( + RecordOperation::Update { + replacements: vec![(ValueId(2), ValueId(3))], + }, + header, + vec![2, 3], + ), + ]; + for (operation, result_type, operands) in cases { + let span = TextRange::new(10, 20); + let baseline = Module { + name: "RecordOperandContract".into(), + externals: Vec::new(), + representations: table.clone(), + entry: None, + span, + functions: vec![Function { + symbol: SymbolId::new(ModuleId(0), 0), + name: "operation".into(), + parameters: (0..4).map(ValueId).collect(), + values: [ + header, + ValueShape::Integer, + ValueShape::String, + erased, + result_type, + ] + .into_iter() + .enumerate() + .map(|(id, ty)| ValueDecl { + id: ValueId(id as u32), + ty, + }) + .collect(), + assignments: vec![Assignment { + destination: ValueId(4), + span, + kind: AssignmentKind::Record { + destination: ValueId(4), + record: ValueId(0), + operation: operation.clone(), + }, + }], + result: ValueId(4), + result_type, + span, + }], + }; + crate::cc::verify_module(&baseline) + .expect("the independently typed operation is valid"); + let reject = |module: &Module, mutation: &str| { + let errors = crate::cc::verify_module(module).expect_err(mutation); + assert!( + errors + .iter() + .all(|error| error.kind == BackendErrorKind::InvalidCompilerIr), + "{mutation}: {errors:?}" + ); + assert!( + errors.iter().any(|error| error.span == span), + "{mutation}: {errors:?}" + ); + }; + for operand in std::iter::once(0).chain(operands) { + let mut wrong_shape = baseline.clone(); + wrong_shape.functions[0].values[operand].ty = ValueShape::Number; + reject( + &wrong_shape, + "a record operand cannot change its checked use type", + ); + let mut missing = baseline.clone(); + missing.functions[0].values.remove(operand); + reject(&missing, "a record operand must have a declaration"); + } + let mut wrong_result = baseline.clone(); + wrong_result.functions[0].values[4].ty = ValueShape::Number; + wrong_result.functions[0].result_type = ValueShape::Number; + reject( + &wrong_result, + "the result must match the operation's contract", + ); + let mut wrong_destination = baseline.clone(); + let AssignmentKind::Record { destination, .. } = + &mut wrong_destination.functions[0].assignments[0].kind + else { + unreachable!() + }; + *destination = ValueId(1); + reject( + &wrong_destination, + "embedded and published destinations must agree", + ); + } + } +} diff --git a/crates/psrs-backend/src/cc/verify/ops/table.rs b/crates/psrs-backend/src/cc/verify/ops/table.rs index 5f609209..6add04c8 100644 --- a/crates/psrs-backend/src/cc/verify/ops/table.rs +++ b/crates/psrs-backend/src/cc/verify/ops/table.rs @@ -8,6 +8,9 @@ pub(crate) fn verify_table( table: &RepresentationTable, span: TextRange, ) -> Result<(), Vec> { + if let Err(message) = table.verify_record_storage() { + return Err(table_error(span, message)); + } let mut roles = HashSet::new(); let mut helpers = HashSet::new(); for helper in table.control_helpers() { diff --git a/crates/psrs-backend/src/mir/gc_tests/mod.rs b/crates/psrs-backend/src/mir/gc_tests/mod.rs index e325fcb5..a4f17382 100644 --- a/crates/psrs-backend/src/mir/gc_tests/mod.rs +++ b/crates/psrs-backend/src/mir/gc_tests/mod.rs @@ -11,6 +11,7 @@ mod binary_matrix; mod erased; mod number_trunc; mod rank_n; +mod record; mod scalar; mod unary; mod variant; diff --git a/crates/psrs-backend/src/mir/gc_tests/rank_n/mod.rs b/crates/psrs-backend/src/mir/gc_tests/rank_n/mod.rs index e8f86558..da1baa30 100644 --- a/crates/psrs-backend/src/mir/gc_tests/rank_n/mod.rs +++ b/crates/psrs-backend/src/mir/gc_tests/rank_n/mod.rs @@ -390,6 +390,7 @@ pub(super) fn module(types: Vec, declarations: Vec, entry: Sy external_types: Vec::new(), types, newtype_ids: Vec::new(), + ordinary_data_ids: Vec::new(), opaque_ids: Vec::new(), callable_types: Vec::new(), constructors: Vec::new(), diff --git a/crates/psrs-backend/src/mir/gc_tests/record/build.rs b/crates/psrs-backend/src/mir/gc_tests/record/build.rs new file mode 100644 index 00000000..480a827f --- /dev/null +++ b/crates/psrs-backend/src/mir/gc_tests/record/build.rs @@ -0,0 +1,370 @@ +use super::*; +use crate::cc::{ + AggregateConvert, External, RecordOperation, Reference, Representation, Signature, + ValueConversion, ValueDecl, +}; +use crate::types::{CompositeType, HeapType, RefType, StorageType, ValueType}; +use crate::{ExternalBindings, RuntimeBinding}; +use psrs_hir::{FOREIGN_SYMBOL_BASE, ModuleId}; + +fn reference(representation: crate::cc::ReprId) -> ValueShape { + ValueShape::Reference(Reference { + nullable: false, + heap: crate::cc::RefShape::Repr(representation), + }) +} + +fn build_module() -> (CcModule, ExternalBindings, [SymbolId; 2]) { + let mut representations = RepresentationTable::default(); + let input_array = representations.reserve(); + representations.set( + input_array, + Representation::Array { + element: crate::cc::payload::erased_shape(), + }, + ); + let storage = representations.ensure_record_storage(); + let build = SymbolId::new(ModuleId::INTRINSICS, FOREIGN_SYMBOL_BASE); + let length = SymbolId::new(ModuleId::INTRINSICS, FOREIGN_SYMBOL_BASE + 1); + let span = span(); + let module = CcModule { + name: "CheckedRecordBuild".into(), + externals: vec![ + External { + projection: None, + symbol: build, + signature: Some(Signature { + parameters: vec![reference(input_array), reference(input_array)], + result: reference(storage.representation()), + }), + }, + External { + projection: None, + symbol: length, + signature: Some(Signature { + parameters: vec![reference(storage.representation())], + result: ValueShape::Integer, + }), + }, + ], + representations, + functions: vec![CcFunction { + symbol: SymbolId::new(ModuleId(0), 0), + name: "main".into(), + parameters: Vec::new(), + values: vec![ + ValueDecl { + id: ValueId(0), + ty: ValueShape::String, + }, + ValueDecl { + id: ValueId(1), + ty: crate::cc::payload::erased_shape(), + }, + ValueDecl { + id: ValueId(2), + ty: reference(input_array), + }, + ValueDecl { + id: ValueId(3), + ty: reference(input_array), + }, + ValueDecl { + id: ValueId(4), + ty: reference(storage.representation()), + }, + ValueDecl { + id: ValueId(5), + ty: ValueShape::Integer, + }, + ], + assignments: vec![ + Assignment { + destination: ValueId(0), + kind: AssignmentKind::StringConstant("a".into()), + span, + }, + Assignment { + destination: ValueId(1), + kind: AssignmentKind::AggregateConvert { + destination: ValueId(1), + value: ValueId(0), + conversion: AggregateConvert { + source: ValueShape::String, + destination: crate::cc::payload::erased_shape(), + plan: ValueConversion::EraseReference, + }, + }, + span, + }, + Assignment { + destination: ValueId(2), + kind: AssignmentKind::ArrayNew { + destination: ValueId(2), + representation: input_array, + elements: vec![ValueId(1)], + }, + span, + }, + Assignment { + destination: ValueId(3), + kind: AssignmentKind::ArrayNew { + destination: ValueId(3), + representation: input_array, + elements: vec![ValueId(1)], + }, + span, + }, + Assignment { + destination: ValueId(4), + kind: AssignmentKind::RecordPrimitive { + provider: build, + record: None, + operation: RecordOperation::Build { + labels: ValueId(2), + values: ValueId(3), + }, + }, + span, + }, + Assignment { + destination: ValueId(5), + kind: AssignmentKind::RecordPrimitive { + provider: length, + record: Some(ValueId(4)), + operation: RecordOperation::Length, + }, + span, + }, + ], + result: ValueId(5), + result_type: ValueShape::Integer, + span, + }], + entry: Some(SymbolId::new(ModuleId(0), 0)), + span, + }; + let bindings = ExternalBindings { + imports: Vec::new(), + runtime: [(build, "build"), (length, "length")] + .into_iter() + .map(|(symbol, function)| RuntimeBinding { + symbol, + source_module: ModuleId(0), + module: psrs_runtime::RECORD_STORAGE_MODULE.into(), + function: function.into(), + type_id: None, + span, + }) + .collect(), + }; + (module, bindings, [build, length]) +} + +fn lower_build() -> crate::mir::Module { + let (module, bindings, _) = build_module(); + crate::cc::verify_module(&module).expect("checked provider-backed Build CC"); + crate::mir::lower_module_with_bindings(module, bindings, crate::TargetCapabilities::default()) + .expect("checked Build provider lowers through the record owner") + .0 +} + +fn assert_p9_rejection(module: CcModule, bindings: ExternalBindings, pass: &str, message: &str) { + crate::cc::verify_module(&module).expect("malformed provider cases retain valid CC shape"); + let errors = match crate::mir::lower_module_with_bindings( + module, + bindings, + crate::TargetCapabilities::default(), + ) { + Err(errors) => errors, + Ok(_) => panic!("malformed provider metadata must be rejected by P9"), + }; + assert!( + errors + .iter() + .any(|error| error.pass == pass && error.message == message), + "expected {pass:?} rejection {message:?}, got {errors:#?}" + ); +} + +#[test] +fn p9_record_provider_valid_base_passes_cc_and_p9() { + let (module, bindings, _) = build_module(); + crate::cc::verify_module(&module).expect("valid provider-backed Build CC"); + crate::mir::lower_module_with_bindings(module, bindings, crate::TargetCapabilities::default()) + .expect("valid provider bindings pass P9"); +} + +#[test] +fn p9_record_provider_rejects_another_known_provider_symbol() { + let (mut module, bindings, [_, length]) = build_module(); + let AssignmentKind::RecordPrimitive { provider, .. } = + &mut module.functions[0].assignments[4].kind + else { + panic!("fixture Build assignment retains its provider witness"); + }; + *provider = length; + + assert_p9_rejection( + module, + bindings, + "P9 runtime projection", + "CC record operation disagrees with its checked provider export", + ); +} + +#[test] +fn p9_record_provider_rejects_a_missing_runtime_binding() { + let (module, mut bindings, [build, _]) = build_module(); + bindings.runtime.retain(|binding| binding.symbol != build); + + assert_p9_rejection( + module, + bindings, + "P9 external binding validation", + &format!("CC external {build:?} has no binding"), + ); +} + +// P9's entry point delegates export signature checking to the P8 runtime ABI owner. +#[test] +fn p9_record_provider_rejects_an_export_for_a_different_operation() { + let (module, mut bindings, [build, _]) = build_module(); + let binding = bindings + .runtime + .iter_mut() + .find(|binding| binding.symbol == build) + .expect("Build provider binding"); + binding.function = "length".into(); + + assert_p9_rejection( + module, + bindings, + "P8 runtime ABI", + "record primitive CC signature has wrong arity", + ); +} + +#[test] +fn p9_record_provider_rejects_direct_call_bypass() { + let (mut module, bindings, [build, _]) = build_module(); + module.functions[0].assignments[4].kind = AssignmentKind::DirectCall { + contract: None, + function: build, + arguments: vec![ValueId(2), ValueId(3)], + }; + + assert_p9_rejection( + module, + bindings, + "P9 runtime projection", + "record provider bypasses its checked CC operation", + ); +} + +// P9's entry point delegates canonical CC signature checks to the P8 runtime ABI owner. +#[test] +fn p9_record_provider_rejects_a_noncanonical_external_signature() { + let (mut module, bindings, [build, _]) = build_module(); + module + .externals + .iter_mut() + .find(|external| external.symbol == build) + .and_then(|external| external.signature.as_mut()) + .expect("Build external signature") + .result = ValueShape::Integer; + + assert_p9_rejection( + module, + bindings, + "P8 runtime ABI", + "record operand has no canonical record shape", + ); +} + +#[test] +fn direct_record_build_executes_through_the_shared_owner() { + let mir = lower_build(); + run_gc(&mir, 1); +} + +#[test] +fn direct_record_build_traps_when_either_input_array_contains_null() { + for null_array in 0..2 { + let mut mir = lower_build(); + let candidate = mir.types.iter().flat_map(|group| &group.0).find_map(|ty| { + let CompositeType::Array(field) = &ty.composite else { + return None; + }; + match field.storage { + StorageType::Ref(reference) + if reference.nullable && reference.heap == HeapType::Eq => + { + Some(*field) + } + _ => None, + } + }); + assert!(candidate.is_some(), "the Build inputs use erased arrays"); + let function = &mut mir.functions[0]; + let (block, instruction, span) = function + .blocks + .iter() + .enumerate() + .flat_map(|(block, body)| { + body.instructions + .iter() + .enumerate() + .filter_map(move |(instruction, value)| match value { + Instruction::ArrayNew { elements, span, .. } if elements.len() == 1 => { + Some((block, instruction, *span)) + } + _ => None, + }) + }) + .nth(null_array) + .expect("the fixture has label and payload arrays"); + let null = ValueId( + function + .values + .iter() + .map(|value| value.id.0) + .max() + .unwrap() + + 1, + ); + function.values.push(crate::types::ValueDecl { + id: null, + ty: ValueType::Ref(RefType { + nullable: true, + heap: HeapType::Eq, + }), + }); + let body = &mut function.blocks[block]; + let Instruction::ArrayNew { elements, .. } = &mut body.instructions[instruction] else { + unreachable!("selected array creation") + }; + elements[0] = null; + body.instructions.insert( + instruction, + Instruction::RefNull { + destination: null, + heap: HeapType::Eq, + span, + }, + ); + crate::mir::verify_module(&mir).expect("nullable null elements are valid erased arrays"); + if let Some(output) = run_gc_output(&mir) { + assert!(!output.status.success(), "{output:?}"); + let expected = if null_array == 0 { + "cast failure" + } else { + "unreachable" + }; + assert!( + String::from_utf8_lossy(&output.stderr).contains(expected), + "{output:?}" + ); + } + } +} diff --git a/crates/psrs-backend/src/mir/gc_tests/record/malformed.rs b/crates/psrs-backend/src/mir/gc_tests/record/malformed.rs new file mode 100644 index 00000000..0ea64b7c --- /dev/null +++ b/crates/psrs-backend/src/mir/gc_tests/record/malformed.rs @@ -0,0 +1,106 @@ +use super::*; + +fn read_only_fixture(short_payload: bool, kind: usize) -> CcModule { + let mut module = roundtrip(false, short_payload, None); + let storage = module.representations.record_storage().unwrap(); + let function = &mut module.functions[0]; + let cut = function + .assignments + .iter() + .position(|assignment| { + if short_payload { + matches!(assignment.kind, AssignmentKind::ProductNew { representation, .. } + if representation == storage.representation()) + } else { + matches!(&assignment.kind, AssignmentKind::AggregateConvert { conversion, .. } + if matches!(conversion.plan, ValueConversion::RecordPack { .. })) + } + }) + .unwrap(); + let record = function.assignments[cut].destination; + function.assignments.truncate(cut + 1); + let success = function + .assignments + .iter() + .find_map(|assignment| { + matches!(assignment.kind, AssignmentKind::Constant(42)) + .then_some(assignment.destination) + }) + .unwrap(); + let mut add = |ty, kind: fn(ValueId) -> AssignmentKind| { + let id = ValueId(function.values.len() as u32); + function.values.push(ValueDecl { id, ty }); + function.assignments.push(Assignment { + destination: id, + kind: kind(id), + span: span(), + }); + id + }; + let index = add(ValueShape::Integer, |_| AssignmentKind::Constant(0)); + let label = add(ValueShape::String, |_| { + AssignmentKind::StringConstant("alpha".into()) + }); + let (operation, ty) = match kind { + 0 => (RecordOperation::Length, ValueShape::Integer), + 1 => (RecordOperation::LabelAt { index }, ValueShape::String), + 2 => (RecordOperation::Find { label }, ValueShape::Integer), + 3 => ( + RecordOperation::Read { index }, + crate::cc::payload::erased_shape(), + ), + 4 => ( + RecordOperation::Update { + replacements: Vec::new(), + }, + reference(storage.representation()), + ), + _ => unreachable!(), + }; + let destination = ValueId(function.values.len() as u32); + function.values.push(ValueDecl { + id: destination, + ty, + }); + function.assignments.push(Assignment { + destination, + span: span(), + kind: AssignmentKind::Record { + destination, + record, + operation, + }, + }); + // No closed unpack follows this operation: it must enforce its own input + // length contract even when its result is ignored (including empty Update). + function.result = success; + function.result_type = ValueShape::Integer; + module +} + +#[test] +fn each_record_operation_traps_on_mismatched_runtime_arrays_without_a_later_unpack() { + for kind in 0..5 { + for short_payload in [false, true] { + let cc = read_only_fixture(short_payload, kind); + crate::cc::verify_module(&cc).expect("physical CC schema remains valid"); + let (mir, _) = super::super::super::lower_module(cc).expect("record operation MIR"); + super::super::super::verify_module(&mir).expect("physical MIR remains valid"); + if let Some(output) = run_gc_output(&mir) { + if short_payload { + assert!(!output.status.success(), "operation {kind}: {output:?}"); + assert!( + String::from_utf8_lossy(&output.stderr).contains("unreachable"), + "operation {kind}: {output:?}" + ); + } else { + assert_eq!( + output.status.code(), + Some(42), + "operation {kind}: {output:?}" + ); + } + } + } + } +} diff --git a/crates/psrs-backend/src/mir/gc_tests/record/mod.rs b/crates/psrs-backend/src/mir/gc_tests/record/mod.rs new file mode 100644 index 00000000..f1305b5f --- /dev/null +++ b/crates/psrs-backend/src/mir/gc_tests/record/mod.rs @@ -0,0 +1,430 @@ +use super::*; +use crate::cc::{ + AggregateConvert, BoxKind, RecordOperation, RecoveryEvidence, RefShape, ReprId, + ValueConversion, ValueDecl, +}; + +mod build; +mod malformed; + +fn reference(id: ReprId) -> ValueShape { + ValueShape::Reference(Reference { + nullable: false, + heap: RefShape::Repr(id), + }) +} + +#[derive(Default)] +struct Body { + values: Vec, + assignments: Vec, +} + +impl Body { + fn add(&mut self, ty: ValueShape, kind: impl FnOnce(ValueId) -> AssignmentKind) -> ValueId { + let id = ValueId(self.values.len() as u32); + self.values.push(ValueDecl { id, ty }); + self.assignments.push(Assignment { + destination: id, + kind: kind(id), + span: span(), + }); + id + } +} + +fn roundtrip(wrong_label: bool, short_payload: bool, update_label: Option<&str>) -> CcModule { + let mut table = RepresentationTable::default(); + let boxed = table.reserve(); + table.set( + boxed, + Representation::Box { + value: ValueShape::Integer, + }, + ); + let array = table.reserve(); + table.set( + array, + Representation::Array { + element: ValueShape::Integer, + }, + ); + let source = table.reserve(); + let target = table.reserve(); + let fields = vec![ValueShape::String, reference(array), ValueShape::Integer]; + table.set( + source, + Representation::Product { + fields: fields.clone(), + }, + ); + table.set(target, Representation::Product { fields }); + let labels = vec!["alpha".into(), "list".into(), "value".into()]; + let mut input_labels = labels.clone(); + if wrong_label { + input_labels[2] = "wrong".into(); + } + table.set_product_labels(source, input_labels.clone()); + table.set_product_labels(target, labels.clone()); + let storage = table.ensure_record_storage(); + let erase_int = ValueConversion::Sequence(vec![ + ValueConversion::BoxScalar { + kind: BoxKind::Integer, + representation: boxed, + }, + ValueConversion::EraseReference, + ]); + let recover_int = ValueConversion::UnboxScalar { + kind: BoxKind::Integer, + representation: boxed, + destination: ValueShape::Integer, + }; + let erase_fields = vec![ + ValueConversion::EraseReference, + ValueConversion::Sequence(vec![ + ValueConversion::ArrayMap { + source: array, + target: storage.values(), + element: Box::new(erase_int.clone()), + }, + ValueConversion::EraseReference, + ]), + erase_int, + ]; + let recover_fields = vec![ + ValueConversion::RecoverReference { + destination: ValueShape::String, + evidence: RecoveryEvidence::TypeInstantiation, + }, + ValueConversion::Sequence(vec![ + ValueConversion::RecoverReference { + destination: reference(storage.values()), + evidence: RecoveryEvidence::TypeInstantiation, + }, + ValueConversion::ArrayMap { + source: storage.values(), + target: array, + element: Box::new(recover_int.clone()), + }, + ]), + recover_int, + ]; + let mut body = Body::default(); + // A non-null String field precedes an ArrayMap introducing control labels. + let text = body.add(ValueShape::String, |_: ValueId| { + AssignmentKind::StringConstant("λ😀".into()) + }); + let n = body.add(ValueShape::Integer, |_| AssignmentKind::Constant(42)); + let other = body.add(ValueShape::Integer, |_| AssignmentKind::Constant(37)); + let input_array = body.add(reference(array), |id| AssignmentKind::ArrayNew { + destination: id, + representation: array, + elements: vec![other, n], + }); + let input = body.add(reference(source), |id| AssignmentKind::ProductNew { + destination: id, + representation: source, + arguments: vec![text, input_array, n], + }); + let mut packed = body.add(reference(storage.representation()), |id| { + AssignmentKind::AggregateConvert { + destination: id, + value: input, + conversion: AggregateConvert { + source: reference(source), + destination: reference(storage.representation()), + plan: ValueConversion::RecordPack { + source, + target: storage.representation(), + labels: input_labels, + fields: erase_fields, + }, + }, + } + }); + if short_payload { + let keys = body.add(reference(storage.labels()), |id| { + AssignmentKind::ProductGet { + destination: id, + representation: storage.representation(), + field: 0, + value: packed, + } + }); + let empty = body.add(reference(storage.values()), |id| AssignmentKind::ArrayNew { + destination: id, + representation: storage.values(), + elements: Vec::new(), + }); + packed = body.add(reference(storage.representation()), |id| { + AssignmentKind::ProductNew { + destination: id, + representation: storage.representation(), + arguments: vec![keys, empty], + } + }); + } + if let Some(update_label) = update_label { + // The replacement payload erases through the same owner policy as the + // pack step before entering the private labelled slot. + let replacement = body.add(ValueShape::Integer, |_| AssignmentKind::Constant(77)); + let replacement_erased = crate::cc::payload::erased_shape(); + let erase_replacement = ValueConversion::Sequence(vec![ + ValueConversion::BoxScalar { + kind: BoxKind::Integer, + representation: boxed, + }, + ValueConversion::EraseReference, + ]); + let erased_value = body.add(replacement_erased, |id| AssignmentKind::AggregateConvert { + destination: id, + value: replacement, + conversion: AggregateConvert { + source: ValueShape::Integer, + destination: replacement_erased, + plan: erase_replacement, + }, + }); + let label = body.add(ValueShape::String, |_| { + AssignmentKind::StringConstant(update_label.into()) + }); + packed = body.add(reference(storage.representation()), |id| { + AssignmentKind::Record { + destination: id, + record: packed, + operation: RecordOperation::Update { + replacements: vec![(label, erased_value)], + }, + } + }); + } + let output = body.add(reference(target), |id| AssignmentKind::AggregateConvert { + destination: id, + value: packed, + conversion: AggregateConvert { + source: reference(storage.representation()), + destination: reference(target), + plan: ValueConversion::RecordUnpack { + source: storage.representation(), + target, + labels, + fields: recover_fields, + }, + }, + }); + let output_text = body.add(ValueShape::String, |id| AssignmentKind::ProductGet { + destination: id, + representation: target, + field: 0, + value: output, + }); + let output_array = body.add(reference(array), |id| AssignmentKind::ProductGet { + destination: id, + representation: target, + field: 1, + value: output, + }); + let output_number = body.add(ValueShape::Integer, |id| AssignmentKind::ProductGet { + destination: id, + representation: target, + field: 2, + value: output, + }); + let equal = body.add(ValueShape::Boolean, |_| AssignmentKind::Primitive { + op: crate::cc::BinaryOp::StringEq, + left: text, + right: output_text, + }); + let text_score = body.add(ValueShape::Integer, |_| AssignmentKind::Unary { + op: crate::cc::UnaryOp::BooleanToInt, + value: equal, + }); + let index = body.add(ValueShape::Integer, |_| AssignmentKind::Constant(1)); + let array_number = body.add(ValueShape::Integer, |id| AssignmentKind::ArrayGet { + destination: id, + representation: array, + value: output_array, + index, + }); + let sum = body.add(ValueShape::Integer, |_| AssignmentKind::Primitive { + op: crate::cc::BinaryOp::IntAdd, + left: output_number, + right: array_number, + }); + let result = body.add(ValueShape::Integer, |_| AssignmentKind::Primitive { + op: crate::cc::BinaryOp::IntAdd, + left: sum, + right: text_score, + }); + let symbol = SymbolId::new(ModuleId(0), 0); + CcModule { + name: "RecordStoreRoundtrip".into(), + externals: Vec::new(), + representations: table, + functions: vec![CcFunction { + symbol, + name: "main".into(), + parameters: Vec::new(), + values: body.values, + assignments: body.assignments, + result, + result_type: ValueShape::Integer, + span: span(), + }], + entry: Some(symbol), + span: span(), + } +} + +#[test] +fn record_store_roundtrip_preserves_strings_and_nested_array_maps() { + let cc = roundtrip(false, false, None); + crate::cc::verify_module(&cc).expect("checked pack/unpack CC"); + let (mir, _) = super::super::lower_module(cc).expect("record conversions lower to MIR"); + super::super::verify_module(&mir).expect("record MIR physical contracts"); + run_gc(&mir, 85); +} + +#[test] +fn record_update_replaces_one_label_in_fresh_storage_and_keeps_the_rest() { + let cc = roundtrip(false, false, Some("value")); + crate::cc::verify_module(&cc).expect("checked record update CC"); + let (mir, _) = super::super::lower_module(cc).expect("record update lowers to MIR"); + super::super::verify_module(&mir).expect("record update MIR physical contracts"); + // The text (score 1) and the list element (42) survive the update; the + // replaced `value` field reads as 77: 1 + 42 + 77. + run_gc(&mir, 120); +} + +#[test] +fn record_update_traps_on_a_label_missing_from_the_runtime_store() { + let cc = roundtrip(false, false, Some("absent")); + crate::cc::verify_module(&cc).expect("checked record update CC"); + let (mir, _) = super::super::lower_module(cc).expect("checked record update MIR"); + if let Some(output) = run_gc_output(&mir) { + assert!(!output.status.success(), "{output:?}"); + assert!( + String::from_utf8_lossy(&output.stderr) + .contains("wasm trap: wasm `unreachable` instruction executed"), + "{output:?}" + ); + } +} + +fn update_replacements(module: &mut CcModule) -> &mut (ValueId, ValueId) { + module.functions[0] + .assignments + .iter_mut() + .find_map(|assignment| match &mut assignment.kind { + AssignmentKind::Record { + operation: RecordOperation::Update { replacements }, + .. + } => replacements.first_mut(), + _ => None, + }) + .expect("record update fixture") +} + +#[test] +fn forged_record_update_operands_fail_at_the_public_cc_boundary() { + let baseline = roundtrip(false, false, Some("value")); + crate::cc::verify_module(&baseline).unwrap(); + let mutations: &[fn(&mut (ValueId, ValueId))] = &[ + |pair| std::mem::swap(&mut pair.0, &mut pair.1), + |pair| pair.1 = pair.0, + ]; + for (index, mutate) in mutations.iter().enumerate() { + let mut module = baseline.clone(); + mutate(update_replacements(&mut module)); + assert!( + crate::cc::verify_module(&module).is_err(), + "forged record update {index} accepted" + ); + } +} + +#[test] +fn record_store_recovery_rejects_wrong_labels_and_payload_lengths() { + for (wrong_label, short_payload) in [(true, false), (false, true)] { + let cc = roundtrip(wrong_label, short_payload, None); + crate::cc::verify_module(&cc).expect("physical inputs remain valid CC"); + let (mir, _) = super::super::lower_module(cc).expect("checked record recovery MIR"); + if let Some(output) = run_gc_output(&mir) { + assert!(!output.status.success(), "{output:?}"); + assert!( + String::from_utf8_lossy(&output.stderr) + .contains("wasm trap: wasm `unreachable` instruction executed"), + "{output:?}" + ); + } + } +} + +fn record_plan(module: &mut CcModule, packing: bool) -> &mut ValueConversion { + module.functions[0] + .assignments + .iter_mut() + .find_map(|assignment| { + if let AssignmentKind::AggregateConvert { conversion, .. } = &mut assignment.kind + && matches!( + (&conversion.plan, packing), + (ValueConversion::RecordPack { .. }, true) + | (ValueConversion::RecordUnpack { .. }, false) + ) + { + Some(&mut conversion.plan) + } else { + None + } + }) + .expect("record conversion fixture") +} + +#[test] +fn forged_record_conversion_plans_fail_at_the_public_cc_boundary() { + let baseline = roundtrip(false, false, Some("value")); + crate::cc::verify_module(&baseline).unwrap(); + let mutations: &[fn(&mut CcModule)] = &[ + |module| { + if let ValueConversion::RecordPack { labels, .. } = record_plan(module, true) { + labels.swap(0, 1); + } + }, + |module| { + if let ValueConversion::RecordPack { fields, .. } = record_plan(module, true) { + fields.pop(); + } + }, + |module| { + if let ValueConversion::RecordPack { fields, .. } = record_plan(module, true) { + fields[0] = ValueConversion::Identity; + } + }, + |module| { + if let ValueConversion::RecordUnpack { fields, .. } = record_plan(module, false) { + fields[2] = ValueConversion::Identity; + } + }, + |module| { + if let ValueConversion::RecordUnpack { source, target, .. } = record_plan(module, false) + { + std::mem::swap(source, target); + } + }, + |module| { + module.representations = RepresentationTable::from_parts( + module.representations.representations.clone(), + module.representations.signatures.clone(), + module.representations.product_labels.clone(), + ); + }, + ]; + for (index, mutate) in mutations.iter().enumerate() { + let mut module = baseline.clone(); + mutate(&mut module); + assert!( + crate::cc::verify_module(&module).is_err(), + "forged plan {index} accepted" + ); + } +} diff --git a/crates/psrs-backend/src/mir/indirect_tests/aggregate/erased_storage.rs b/crates/psrs-backend/src/mir/indirect_tests/aggregate/erased_storage.rs index 4f46a56e..5303bd3f 100644 --- a/crates/psrs-backend/src/mir/indirect_tests/aggregate/erased_storage.rs +++ b/crates/psrs-backend/src/mir/indirect_tests/aggregate/erased_storage.rs @@ -27,23 +27,7 @@ fn roundtrip(array: bool, include_protocol: bool) -> (cc::Module, ExternalBindin // The canonical protocol is registered by CC's layout owner. It is only // reachable through ABI storage conversion, never through a source value. if include_protocol { - let products = module.representations.product_labels.clone(); - for (_, labels) in products { - let id = module.representations.reserve(); - module.representations.set( - id, - Representation::Product { - fields: vec![erased(); labels.len()], - }, - ); - module.representations.set_product_labels(id, labels); - } - if array { - let id = module.representations.reserve(); - module - .representations - .set(id, Representation::Array { element: erased() }); - } + module.representations.ensure_record_storage(); } let id = module.representations.reserve(); module.representations.set( diff --git a/crates/psrs-backend/src/mir/indirect_tests/record_list/mod.rs b/crates/psrs-backend/src/mir/indirect_tests/record_list/mod.rs index d339ef28..ed7dc0e7 100644 --- a/crates/psrs-backend/src/mir/indirect_tests/record_list/mod.rs +++ b/crates/psrs-backend/src/mir/indirect_tests/record_list/mod.rs @@ -3,6 +3,7 @@ //! directly, like the composite indirect-parameter fixture. mod fixtures; +mod storage; mod tests; use super::lower_module_with_registry; diff --git a/crates/psrs-backend/src/mir/indirect_tests/record_list/storage.rs b/crates/psrs-backend/src/mir/indirect_tests/record_list/storage.rs new file mode 100644 index 00000000..e239a511 --- /dev/null +++ b/crates/psrs-backend/src/mir/indirect_tests/record_list/storage.rs @@ -0,0 +1,207 @@ +//! Canonical lists whose source Array stores records through erased slots. +use super::*; +use crate::cc::payload::{PayloadPlanner, StoragePayloadPlanner}; +use crate::cc::{ + self, Assignment, AssignmentKind, ExternalProjection, Representation, ValueDecl, ValueShape, +}; +use crate::types::ValueId; + +fn erased_array(mut module: cc::Module) -> cc::Module { + let external = &module.externals[0]; + let signature = external.signature.as_ref().unwrap(); + let result = signature.parameters.is_empty(); + let array = if result { + signature.result + } else { + signature.parameters[0] + }; + let mut guest = cc::guest_layout(array, &module.representations).unwrap(); + let GuestLayout::Array { repr, element } = &mut guest else { + panic!("array fixture") + }; + let stored = element.stored; + let array_repr = *repr; + element.stored = cc::payload::erased_shape(); + module.representations.set( + array_repr, + Representation::Array { + element: cc::payload::erased_shape(), + }, + ); + module.representations.ensure_record_storage(); + for value in [ValueShape::Integer, ValueShape::Number] { + let id = module.representations.reserve(); + module + .representations + .set(id, Representation::Box { value }); + } + module.externals[0].projection = Some(if result { + ExternalProjection { + parameters: Vec::new(), + result: Some(guest), + } + } else { + ExternalProjection { + parameters: vec![guest], + result: None, + } + }); + if !result { + let plan = StoragePayloadPlanner(&module.representations) + .erase_payload(stored, module.span) + .unwrap(); + let function = &mut module.functions[0]; + let original = std::mem::take(&mut function.assignments); + let mut next = function + .values + .iter() + .map(|value| value.id.0) + .max() + .unwrap_or(0) + + 1; + for mut assignment in original { + if let AssignmentKind::ArrayNew { + representation, + elements, + .. + } = &mut assignment.kind + && *representation == array_repr + { + for element in elements { + let destination = ValueId(next); + next += 1; + function.values.push(ValueDecl { + id: destination, + ty: cc::payload::erased_shape(), + }); + function.assignments.push(Assignment { + destination, + kind: AssignmentKind::AggregateConvert { + destination, + value: *element, + conversion: cc::AggregateConvert { + source: stored, + destination: cc::payload::erased_shape(), + plan: plan.clone(), + }, + }, + span: assignment.span, + }); + *element = destination; + } + } + function.assignments.push(assignment); + } + } + module +} + +fn lower( + input: (cc::Module, crate::ExternalBindings, wit_parser::Resolve), +) -> (crate::mir::Module, abi::WasiRegistry, TargetCapabilities) { + let (module, bindings, resolve) = input; + let target = TargetCapabilities { + wasi_cli: false, + ..TargetCapabilities::default() + }; + let registry = abi::WasiRegistry::from_resolve(resolve, target); + let (mir, registry) = + lower_module_with_registry(erased_array(module), bindings, target, registry) + .expect("erased record list ABI should lower"); + crate::mir::verify_module(&mir).expect("the record storage owner and physical layout agree"); + (mir, registry, target) +} + +#[test] +fn erased_record_lists_encode_both_abi_directions_and_string_fields() { + for fixture in [ + fixture(), + result_fixture(), + string_fixture(), + flags_fixture(), + ] { + let (mir, mut registry, target) = lower(fixture); + let mir = crate::mir::opt::optimize(mir, target).expect("record list MIR optimization"); + let wasm = crate::wasm::lower_module_with_capabilities(&mir, &mut registry, target) + .expect("record storage ABI encoding"); + let binary = crate::wasm::encode_module(&wasm).expect("record list binary"); + crate::validator_for(target) + .validate_all(&binary) + .expect("both directions validate with erased record storage"); + } +} + +#[test] +fn mir_record_storage_views_require_the_owner_and_exact_gc_schema() { + let (baseline, _, _) = lower(result_fixture()); + let layout = baseline.layout.as_ref().unwrap(); + let storage = layout.representation_table().record_storage().unwrap(); + let header = layout.repr_index(storage.representation()).unwrap(); + let keys = layout.repr_index(storage.labels()).unwrap(); + let mutations: &[fn( + &mut crate::mir::Module, + crate::types::DefinedTypeId, + crate::types::DefinedTypeId, + )] = &[ + |module, _, _| module.layout = None, + |module, header, _| { + module + .types + .iter_mut() + .flat_map(|group| &mut group.0) + .nth(header.0 as usize) + .unwrap() + .composite = crate::types::CompositeType::Struct(Vec::new()) + }, + |module, _, keys| { + module + .types + .iter_mut() + .flat_map(|group| &mut group.0) + .nth(keys.0 as usize) + .unwrap() + .composite = crate::types::CompositeType::Array(crate::types::FieldType { + storage: crate::types::StorageType::I32, + mutable: true, + }) + }, + |module, _, _| { + for instruction in module + .functions + .iter_mut() + .flat_map(|function| &mut function.blocks) + .flat_map(|block| &mut block.instructions) + { + if let Instruction::ListCopy { + element_guest: GuestLayout::RecordStorage { labels, .. }, + .. + } = instruction + { + labels.swap(0, 1); + } + } + }, + |module, _, _| { + for instruction in module + .functions + .iter_mut() + .flat_map(|function| &mut function.blocks) + .flat_map(|block| &mut block.instructions) + { + if let Instruction::ListCopy { struct_type, .. } = instruction { + *struct_type = crate::types::DefinedTypeId(u32::MAX); + } + } + }, + ]; + for (index, mutate) in mutations.iter().enumerate() { + let mut module = baseline.clone(); + mutate(&mut module, header, keys); + let errors = crate::mir::verify_module(&module) + .expect_err("malformed record storage evidence must fail"); + assert!( + errors.iter().any(|error| error.message.contains("record")), + "mutation {index}: {errors:?}" + ); + } +} diff --git a/crates/psrs-backend/src/mir/lower/aggregate/fields.rs b/crates/psrs-backend/src/mir/lower/aggregate/fields.rs new file mode 100644 index 00000000..3ab091cd --- /dev/null +++ b/crates/psrs-backend/src/mir/lower/aggregate/fields.rs @@ -0,0 +1,164 @@ +//! Preserve aggregate fields across nested conversions and control labels. +use super::*; + +#[derive(Clone, Copy)] +pub(in crate::mir::lower) struct StoredValue { + value: ValueId, + restore: Option, +} + +impl FunctionLowerer<'_> { + pub(in crate::mir::lower) fn persist_field( + &mut self, + block: BlockId, + value: ValueId, + span: TextRange, + ) -> Result> { + let ty = self + .value_type(value) + .ok_or_else(|| aggregate_error(span, "aggregate field has no physical type"))?; + if let ValueType::Ref(reference) = ty + && !reference.nullable + { + let nullable = crate::types::RefType { + nullable: true, + ..reference + }; + let destination = self.fresh(ValueType::Ref(nullable)); + self.append_instruction( + block, + Instruction::RefCast { + destination, + value, + reference: nullable, + span, + }, + span, + )?; + Ok(StoredValue { + value: destination, + restore: Some(reference), + }) + } else { + Ok(StoredValue { + value, + restore: None, + }) + } + } + + pub(in crate::mir::lower) fn restore_field( + &mut self, + block: BlockId, + stored: StoredValue, + span: TextRange, + ) -> Result> { + let Some(reference) = stored.restore else { + return Ok(stored.value); + }; + let destination = self.fresh(ValueType::Ref(reference)); + self.append_instruction( + block, + Instruction::RefCast { + destination, + value: stored.value, + reference, + span, + }, + span, + )?; + Ok(destination) + } + + pub(super) fn convert_product_fields( + &mut self, + block: BlockId, + value: ValueId, + source: crate::cc::ReprId, + fields: &[ValueConversion], + span: TextRange, + ) -> Result<(BlockId, Vec), Vec> { + let type_index = self + .layout + .repr_index(source) + .map_err(|error| layout_error(span, error))?; + let mut values = Vec::with_capacity(fields.len()); + // Read all fields before a nested plan introduces branches or loops. + // Physical String references need this just as nominal references do. + for index in 0..fields.len() { + let shape = self + .layout + .product_field(type_index, index as u32) + .map_err(|error| layout_error(span, error))?; + let destination = self.fresh( + self.layout + .value_type(&shape) + .map_err(|error| layout_error(span, error))?, + ); + self.append_instruction( + block, + Instruction::StructGet { + destination, + type_index, + field: index as u32, + value, + span, + }, + span, + )?; + values.push((self.persist_field(block, destination, span)?, shape)); + } + self.convert_stored_fields(block, values, fields, span) + } + + pub(super) fn convert_stored_fields( + &mut self, + mut block: BlockId, + values: Vec<(StoredValue, ValueShape)>, + fields: &[ValueConversion], + span: TextRange, + ) -> Result<(BlockId, Vec), Vec> { + let mut result = Vec::with_capacity(fields.len()); + for ((value, shape), plan) in values.into_iter().zip(fields) { + let value = self.restore_field(block, value, span)?; + let (next, value, _) = self.lower_value_conversion(block, value, shape, plan, span)?; + block = next; + result.push(self.persist_field(block, value, span)?); + } + Ok((block, result)) + } + + pub(super) fn build_product( + &mut self, + block: BlockId, + target: crate::cc::ReprId, + values: Vec, + span: TextRange, + ) -> Result<(BlockId, ValueId, ValueShape), Vec> { + let shape = representation_shape(target); + let destination = self.fresh( + self.layout + .value_type(&shape) + .map_err(|error| layout_error(span, error))?, + ); + let type_index = self + .layout + .repr_index(target) + .map_err(|error| layout_error(span, error))?; + let arguments = values + .into_iter() + .map(|value| self.restore_field(block, value, span)) + .collect::, _>>()?; + self.append_instruction( + block, + Instruction::StructNew { + destination, + type_index, + arguments, + span, + }, + span, + )?; + Ok((block, destination, shape)) + } +} diff --git a/crates/psrs-backend/src/mir/lower/aggregate/mod.rs b/crates/psrs-backend/src/mir/lower/aggregate/mod.rs index 1dffb401..a0b96bac 100644 --- a/crates/psrs-backend/src/mir/lower/aggregate/mod.rs +++ b/crates/psrs-backend/src/mir/lower/aggregate/mod.rs @@ -3,9 +3,11 @@ use crate::cc::{AggregateConvert, RefShape, Reference, ValueConversion, ValueSha use crate::types::{RefType, ValueType}; mod array; +mod fields; +mod record; impl FunctionLowerer<'_> { - pub(super) fn lower_aggregate_convert( + pub(in crate::mir) fn lower_aggregate_convert( &mut self, block: BlockId, value: ValueId, @@ -13,6 +15,24 @@ impl FunctionLowerer<'_> { conversion: &AggregateConvert, span: TextRange, ) -> Result> { + if let Some(function) = self + .conversion_helpers + .as_mut() + .and_then(|helpers| helpers.intern(conversion, span)) + { + self.append_instruction( + block, + Instruction::Call { + contract: None, + destination, + function, + arguments: vec![value], + span, + }, + span, + )?; + return Ok(block); + } let (block, result, shape) = self.lower_value_conversion(block, value, conversion.source, &conversion.plan, span)?; if shape != conversion.destination { @@ -199,6 +219,9 @@ impl FunctionLowerer<'_> { target, element, } => self.lower_array_map(block, value, *source_repr, *target, element, span), + ValueConversion::RecordPack { .. } | ValueConversion::RecordUnpack { .. } => { + self.lower_record_conversion(block, value, plan, span) + } ValueConversion::ProductMap { source: source_repr, target, @@ -236,164 +259,15 @@ impl FunctionLowerer<'_> { fn lower_product_map( &mut self, - mut block: BlockId, + block: BlockId, value: ValueId, source: crate::cc::ReprId, target: crate::cc::ReprId, fields: &[ValueConversion], span: TextRange, ) -> Result<(BlockId, ValueId, ValueShape), Vec> { - let source_index = self - .layout - .repr_index(source) - .map_err(|error| layout_error(span, error))?; - let target_index = self - .layout - .repr_index(target) - .map_err(|error| layout_error(span, error))?; - // Read every source field before a nested ArrayMap introduces loop - // labels. Non-null product references are scoped by the structured - // encoder, so they must not be loaded after leaving their defining - // basic-block region. - let mut source_fields = Vec::with_capacity(fields.len()); - for index in 0..fields.len() { - let source_shape = self - .layout - .product_field(source_index, index as u32) - .map_err(|error| layout_error(span, error))?; - let field_value = self.fresh( - self.layout - .value_type(&source_shape) - .map_err(|error| layout_error(span, error))?, - ); - self.append_instruction( - block, - Instruction::StructGet { - destination: field_value, - type_index: source_index, - field: index as u32, - value, - span, - }, - span, - )?; - let (field_value, is_nullable) = if matches!(source_shape, ValueShape::Reference(reference) if !reference.nullable) - { - let nullable_shape = nullable_reference_shape(source_shape); - let nullable = self.fresh( - self.layout - .value_type(&nullable_shape) - .map_err(|error| layout_error(span, error))?, - ); - self.append_instruction( - block, - Instruction::RefCast { - destination: nullable, - value: field_value, - reference: reference_type(&nullable_shape, self.layout, span)?, - span, - }, - span, - )?; - (nullable, true) - } else { - (field_value, false) - }; - source_fields.push((field_value, source_shape, is_nullable)); - } - - let mut values = Vec::with_capacity(fields.len()); - for ((field_value, source_shape, is_nullable), plan) in - source_fields.into_iter().zip(fields) - { - let field_value = if is_nullable { - let restored = self.fresh( - self.layout - .value_type(&source_shape) - .map_err(|error| layout_error(span, error))?, - ); - self.append_instruction( - block, - Instruction::RefCast { - destination: restored, - value: field_value, - reference: reference_type(&source_shape, self.layout, span)?, - span, - }, - span, - )?; - restored - } else { - field_value - }; - let (next, converted, converted_shape) = - self.lower_value_conversion(block, field_value, source_shape, plan, span)?; - block = next; - let persistent = if matches!(converted_shape, ValueShape::Reference(reference) if !reference.nullable) - { - let nullable_shape = nullable_reference_shape(converted_shape); - let nullable = self.fresh( - self.layout - .value_type(&nullable_shape) - .map_err(|error| layout_error(span, error))?, - ); - self.append_instruction( - block, - Instruction::RefCast { - destination: nullable, - value: converted, - reference: reference_type(&nullable_shape, self.layout, span)?, - span, - }, - span, - )?; - (nullable, Some(converted_shape)) - } else { - (converted, None) - }; - values.push(persistent); - } - let destination_shape = representation_shape(target); - let destination = self.fresh( - self.layout - .value_type(&destination_shape) - .map_err(|error| layout_error(span, error))?, - ); - let arguments = values - .into_iter() - .map(|(value, shape)| { - let Some(shape) = shape else { - return Ok(value); - }; - let restored = self.fresh( - self.layout - .value_type(&shape) - .map_err(|error| layout_error(span, error))?, - ); - self.append_instruction( - block, - Instruction::RefCast { - destination: restored, - value, - reference: reference_type(&shape, self.layout, span)?, - span, - }, - span, - )?; - Ok(restored) - }) - .collect::, Vec>>()?; - self.append_instruction( - block, - Instruction::StructNew { - destination, - type_index: target_index, - arguments, - span, - }, - span, - )?; - Ok((block, destination, destination_shape)) + let (block, values) = self.convert_product_fields(block, value, source, fields, span)?; + self.build_product(block, target, values, span) } } diff --git a/crates/psrs-backend/src/mir/lower/aggregate/record/mod.rs b/crates/psrs-backend/src/mir/lower/aggregate/record/mod.rs new file mode 100644 index 00000000..5d738a40 --- /dev/null +++ b/crates/psrs-backend/src/mir/lower/aggregate/record/mod.rs @@ -0,0 +1,308 @@ +//! Explicit product <-> labelled-store conversion. No reference reinterpretation. +use super::fields::StoredValue; +use super::*; +mod operations; +use crate::cc::{RecordStorage, ReprId}; +use crate::mir::NumericOp; + +struct RecordPlan<'a> { + source: ReprId, + target: ReprId, + labels: &'a [String], + fields: &'a [ValueConversion], +} + +impl FunctionLowerer<'_> { + pub(super) fn lower_record_conversion( + &mut self, + block: BlockId, + value: ValueId, + conversion: &ValueConversion, + span: TextRange, + ) -> Result<(BlockId, ValueId, ValueShape), Vec> { + let storage = self + .layout + .representation_table() + .record_storage() + .ok_or_else(|| { + aggregate_error(span, "record conversion has no registered storage schema") + })?; + let (plan, packing) = match conversion { + ValueConversion::RecordPack { + source, + target, + labels, + fields, + } => ( + RecordPlan { + source: *source, + target: *target, + labels, + fields, + }, + true, + ), + ValueConversion::RecordUnpack { + source, + target, + labels, + fields, + } => ( + RecordPlan { + source: *source, + target: *target, + labels, + fields, + }, + false, + ), + _ => { + return Err(aggregate_error( + span, + "record lowering requires a record conversion plan", + )); + } + }; + if packing { + self.pack_record(block, value, storage, plan, span) + } else { + self.unpack_record(block, value, storage, plan, span) + } + } + + fn pack_record( + &mut self, + block: BlockId, + value: ValueId, + storage: RecordStorage, + plan: RecordPlan<'_>, + span: TextRange, + ) -> Result<(BlockId, ValueId, ValueShape), Vec> { + let (block, fields) = + self.convert_product_fields(block, value, plan.source, plan.fields, span)?; + let values = fields + .into_iter() + .map(|field| self.restore_field(block, field, span)) + .collect::, _>>()?; + let values = self.record_array(block, storage.values(), values, span)?; + let labels = plan + .labels + .iter() + .map(|label| self.record_label(block, label, span)) + .collect::, _>>()?; + let labels = self.record_array(block, storage.labels(), labels, span)?; + let values = vec![ + self.persist_field(block, labels, span)?, + self.persist_field(block, values, span)?, + ]; + self.build_product(block, plan.target, values, span) + } + + fn unpack_record( + &mut self, + mut block: BlockId, + value: ValueId, + storage: RecordStorage, + plan: RecordPlan<'_>, + span: TextRange, + ) -> Result<(BlockId, ValueId, ValueShape), Vec> { + let arrays = self.record_arrays(block, value, storage, span)?; + let count = i32::try_from(plan.labels.len()) + .map_err(|_| aggregate_error(span, "record has too many fields for the target"))?; + let expected_count = self.record_integer(block, count, span)?; + for array in &arrays { + let value = self.restore_field(block, *array, span)?; + let length = self.fresh(ValueType::I32); + self.append_instruction( + block, + Instruction::ArrayLen { + destination: length, + value, + span, + }, + span, + )?; + let equal = self.fresh(ValueType::Boolean); + self.append_instruction( + block, + Instruction::Primitive { + destination: equal, + op: NumericOp::I32Eq, + left: length, + right: expected_count, + span, + }, + span, + )?; + block = self.require_record_condition(block, equal, span)?; + } + // Validate the complete expected row before recovering any field. A + // shape-compatible map with different labels cannot satisfy the plan. + for (index, label) in plan.labels.iter().enumerate() { + let labels = self.restore_field(block, arrays[0], span)?; + let index = self.record_integer(block, index as i32, span)?; + let actual = self.fresh( + self.layout + .value_type(&ValueShape::String) + .map_err(|error| layout_error(span, error))?, + ); + self.lower_array_get(block, actual, storage.labels(), labels, index, span)?; + let expected = self.record_label(block, label, span)?; + let equal = self.fresh(ValueType::Boolean); + block = self.lower_string_eq(block, equal, actual, expected, span)?; + block = self.require_record_condition(block, equal, span)?; + } + let mut fields = Vec::with_capacity(plan.fields.len()); + for index in 0..plan.fields.len() { + let array = self.restore_field(block, arrays[1], span)?; + let index = self.record_integer(block, index as i32, span)?; + let destination = self.fresh( + self.layout + .value_type(&erased_shape()) + .map_err(|error| layout_error(span, error))?, + ); + self.lower_array_get(block, destination, storage.values(), array, index, span)?; + fields.push(( + self.persist_field(block, destination, span)?, + erased_shape(), + )); + } + let (block, fields) = self.convert_stored_fields(block, fields, plan.fields, span)?; + self.build_product(block, plan.target, fields, span) + } + + fn record_arrays( + &mut self, + block: BlockId, + value: ValueId, + storage: RecordStorage, + span: TextRange, + ) -> Result<[StoredValue; 2], Vec> { + let type_index = self + .layout + .repr_index(storage.representation()) + .map_err(|error| layout_error(span, error))?; + let mut read = |field, repr| { + let destination = self.fresh( + self.layout + .value_type(&representation_shape(repr)) + .map_err(|error| layout_error(span, error))?, + ); + self.append_instruction( + block, + Instruction::StructGet { + destination, + type_index, + field, + value, + span, + }, + span, + )?; + self.persist_field(block, destination, span) + }; + Ok([read(0, storage.labels())?, read(1, storage.values())?]) + } + + fn require_record_condition( + &mut self, + block: BlockId, + condition: ValueId, + span: TextRange, + ) -> Result> { + let next = self.new_block(Vec::new()); + let trap = self.new_block(Vec::new()); + self.set_terminator( + block, + Terminator::Branch { + condition, + then_block: next, + else_block: trap, + span, + }, + span, + )?; + self.set_terminator(trap, Terminator::Trap { span }, span)?; + Ok(next) + } + + fn record_array( + &mut self, + block: BlockId, + repr: ReprId, + elements: Vec, + span: TextRange, + ) -> Result> { + let destination = self.fresh( + self.layout + .value_type(&representation_shape(repr)) + .map_err(|error| layout_error(span, error))?, + ); + let type_index = self + .layout + .repr_index(repr) + .map_err(|error| layout_error(span, error))?; + self.append_instruction( + block, + Instruction::ArrayNew { + destination, + type_index, + elements, + span, + }, + span, + )?; + Ok(destination) + } + + fn record_label( + &mut self, + block: BlockId, + label: &str, + span: TextRange, + ) -> Result> { + let destination = self.fresh( + self.layout + .value_type(&ValueShape::String) + .map_err(|error| layout_error(span, error))?, + ); + let type_index = self.layout.string_index().ok_or_else(|| { + aggregate_error(span, "record labels require the target string layout") + })?; + let data_index = self + .literals + .as_mut() + .ok_or_else(|| aggregate_error(span, "record labels require the string literal pool"))? + .intern(label); + self.append_instruction( + block, + Instruction::ArrayNewData { + destination, + type_index, + data_index, + span, + }, + span, + )?; + Ok(destination) + } + + fn record_integer( + &mut self, + block: BlockId, + value: i32, + span: TextRange, + ) -> Result> { + let destination = self.fresh(ValueType::I32); + self.append_instruction( + block, + Instruction::Constant { + destination, + value, + span, + }, + span, + )?; + Ok(destination) + } +} diff --git a/crates/psrs-backend/src/mir/lower/aggregate/record/operations/build.rs b/crates/psrs-backend/src/mir/lower/aggregate/record/operations/build.rs new file mode 100644 index 00000000..4565e049 --- /dev/null +++ b/crates/psrs-backend/src/mir/lower/aggregate/record/operations/build.rs @@ -0,0 +1,433 @@ +use super::super::*; +use crate::cc::{RefShape, Reference, ValueShape}; +use crate::mir::instruction::Instruction; +use crate::mir::{NumericOp, Terminator}; +use crate::types::{ValueId, ValueType}; +use psrs_span::TextRange; + +impl FunctionLowerer<'_> { + /// Snapshot both checked source arrays, then verify equal length, non-null + /// elements, and strictly increasing Unicode-scalar labels before making a + /// fresh record header. + pub(in crate::mir::lower) fn lower_record_build_operation( + &mut self, + block: BlockId, + destination: ValueId, + labels: ValueId, + payloads: ValueId, + span: TextRange, + ) -> Result> { + let storage = self + .layout + .representation_table() + .record_storage() + .ok_or_else(|| { + aggregate_error(span, "record build has no registered storage schema") + })?; + let labels = self.clone_record_array(block, labels, storage.values(), span)?; + let payloads = self.clone_record_array(block, payloads, storage.values(), span)?; + let labels = self.persist_field(block, labels, span)?; + let payloads = self.persist_field(block, payloads, span)?; + let label_array_length = self.restore_field(block, labels, span)?; + let payload_array_length = self.restore_field(block, payloads, span)?; + let label_length = self.record_array_length(block, label_array_length, span)?; + let payload_length = self.record_array_length(block, payload_array_length, span)?; + let equal_lengths = self.fresh(ValueType::Boolean); + self.append_instruction( + block, + Instruction::Primitive { + destination: equal_lengths, + op: NumericOp::I32Eq, + left: label_length, + right: payload_length, + span, + }, + span, + )?; + let mut block = self.require_record_condition(block, equal_lengths, span)?; + let zero = self.record_integer(block, 0, span)?; + let nonnegative = self.fresh(ValueType::Boolean); + self.append_instruction( + block, + Instruction::Primitive { + destination: nonnegative, + op: NumericOp::I32GeS, + left: label_length, + right: zero, + span, + }, + span, + )?; + block = self.require_record_condition(block, nonnegative, span)?; + + let label_array = self.fresh( + self.layout + .value_type(&ValueShape::Reference(Reference { + nullable: true, + heap: RefShape::Repr(storage.labels()), + })) + .map_err(|error| layout_error(span, error))?, + ); + self.append_instruction( + block, + Instruction::ArrayNewSized { + destination: label_array, + type_index: self + .layout + .repr_index(storage.labels()) + .map_err(|error| layout_error(span, error))?, + length: label_length, + span, + }, + span, + )?; + let label_array = self.persist_field(block, label_array, span)?; + + let body = self.new_block(Vec::new()); + let done = self.new_block(Vec::new()); + let index = self.fresh(ValueType::I32); + let loop_header = self.new_block(vec![index]); + let zero = self.record_integer(block, 0, span)?; + self.set_terminator( + block, + Terminator::Jump { + target: loop_header, + arguments: vec![zero], + span, + }, + span, + )?; + let in_range = self.fresh(ValueType::Boolean); + self.append_instruction( + loop_header, + Instruction::Primitive { + destination: in_range, + op: NumericOp::I32LtS, + left: index, + right: label_length, + span, + }, + span, + )?; + self.set_terminator( + loop_header, + Terminator::Branch { + condition: in_range, + then_block: body, + else_block: done, + span, + }, + span, + )?; + + let label_type = self + .layout + .value_type(&ValueShape::String) + .map_err(|error| layout_error(span, error))?; + let labels_at_body = self.restore_field(body, labels, span)?; + let raw_current = self.fresh( + self.layout + .value_type(&erased_shape()) + .map_err(|error| layout_error(span, error))?, + ); + self.lower_array_get( + body, + raw_current, + storage.values(), + labels_at_body, + index, + span, + )?; + let current = self.recover_record_label(body, raw_current, label_type, span)?; + let current = self.persist_field(body, current, span)?; + let has_previous = self.fresh(ValueType::Boolean); + let zero = self.record_integer(body, 0, span)?; + self.append_instruction( + body, + Instruction::Primitive { + destination: has_previous, + op: NumericOp::I32GtS, + left: index, + right: zero, + span, + }, + span, + )?; + let compare = self.new_block(Vec::new()); + let skip = self.new_block(Vec::new()); + let checked = self.new_block(Vec::new()); + self.set_terminator( + body, + Terminator::Branch { + condition: has_previous, + then_block: compare, + else_block: skip, + span, + }, + span, + )?; + + let previous_index = self.fresh(ValueType::I32); + let one = self.record_integer(compare, 1, span)?; + self.append_instruction( + compare, + Instruction::Primitive { + destination: previous_index, + op: NumericOp::I32Sub, + left: index, + right: one, + span, + }, + span, + )?; + let labels_at_compare = self.restore_field(compare, labels, span)?; + let raw_previous = self.fresh( + self.layout + .value_type(&erased_shape()) + .map_err(|error| layout_error(span, error))?, + ); + self.lower_array_get( + compare, + raw_previous, + storage.values(), + labels_at_compare, + previous_index, + span, + )?; + let previous = self.recover_record_label(compare, raw_previous, label_type, span)?; + let previous = self.persist_field(compare, previous, span)?; + let current_at_compare = self.restore_field(compare, current, span)?; + let ordered = self.fresh(ValueType::Boolean); + let previous = self.restore_field(compare, previous, span)?; + let compare = self.lower_string_lt(compare, ordered, previous, current_at_compare, span)?; + let compare = self.require_record_condition(compare, ordered, span)?; + self.set_terminator( + compare, + Terminator::Jump { + target: checked, + arguments: Vec::new(), + span, + }, + span, + )?; + self.set_terminator( + skip, + Terminator::Jump { + target: checked, + arguments: Vec::new(), + span, + }, + span, + )?; + + let current_at_checked = self.restore_field(checked, current, span)?; + let label_array_at_checked = self.restore_field(checked, label_array, span)?; + self.append_instruction( + checked, + Instruction::ArraySet { + type_index: self + .layout + .repr_index(storage.labels()) + .map_err(|error| layout_error(span, error))?, + value: label_array_at_checked, + index, + new_value: current_at_checked, + span, + }, + span, + )?; + + let nullable_payload = ValueShape::Reference(Reference { + nullable: true, + heap: RefShape::Erased, + }); + let payload_type = self + .layout + .value_type(&nullable_payload) + .map_err(|error| layout_error(span, error))?; + let payload = self.fresh(payload_type); + let payloads_at_checked = self.restore_field(checked, payloads, span)?; + self.lower_array_get( + checked, + payload, + storage.values(), + payloads_at_checked, + index, + span, + )?; + let is_null = self.fresh(ValueType::Boolean); + self.append_instruction( + checked, + Instruction::RefIsNull { + destination: is_null, + value: payload, + span, + }, + span, + )?; + let false_value = self.fresh(ValueType::Boolean); + self.append_instruction( + checked, + Instruction::Constant { + destination: false_value, + value: 0, + span, + }, + span, + )?; + let nonnull = self.fresh(ValueType::Boolean); + self.append_instruction( + checked, + Instruction::Primitive { + destination: nonnull, + op: NumericOp::BoolEq, + left: is_null, + right: false_value, + span, + }, + span, + )?; + let checked = self.require_record_condition(checked, nonnull, span)?; + let next = self.fresh(ValueType::I32); + let one = self.record_integer(checked, 1, span)?; + self.append_instruction( + checked, + Instruction::Primitive { + destination: next, + op: NumericOp::I32Add, + left: index, + right: one, + span, + }, + span, + )?; + self.set_terminator( + checked, + Terminator::Jump { + target: loop_header, + arguments: vec![next], + span, + }, + span, + )?; + + let label_array_at_done = self.restore_field(done, label_array, span)?; + let labels = self.fresh( + self.layout + .value_type(&ValueShape::Reference(Reference { + nullable: false, + heap: RefShape::Repr(storage.labels()), + })) + .map_err(|error| layout_error(span, error))?, + ); + self.append_instruction( + done, + Instruction::RefCast { + destination: labels, + value: label_array_at_done, + reference: crate::types::RefType { + nullable: false, + heap: crate::types::HeapType::Index( + self.layout + .repr_index(storage.labels()) + .map_err(|error| layout_error(span, error))?, + ), + }, + span, + }, + span, + )?; + let payloads_at_done = self.restore_field(done, payloads, span)?; + self.append_instruction( + done, + Instruction::StructNew { + destination, + type_index: self + .layout + .repr_index(storage.representation()) + .map_err(|error| layout_error(span, error))?, + arguments: vec![labels, payloads_at_done], + span, + }, + span, + )?; + Ok(done) + } + + fn recover_record_label( + &mut self, + block: BlockId, + value: ValueId, + destination_type: ValueType, + span: TextRange, + ) -> Result> { + let destination = self.fresh(destination_type); + let string_type = self.layout.string_index().ok_or_else(|| { + layout_error(span, crate::mir::layout::LayoutError::UnknownRepresentation) + })?; + self.append_instruction( + block, + Instruction::RefCast { + destination, + value, + reference: crate::types::RefType { + nullable: false, + heap: crate::types::HeapType::Index(string_type), + }, + span, + }, + span, + )?; + Ok(destination) + } + + fn clone_record_array( + &mut self, + block: BlockId, + value: ValueId, + representation: crate::cc::ReprId, + span: TextRange, + ) -> Result> { + let destination = self.fresh( + self.layout + .value_type(&ValueShape::Reference(Reference { + nullable: false, + heap: RefShape::Repr(representation), + })) + .map_err(|error| layout_error(span, error))?, + ); + self.append_instruction( + block, + Instruction::ArrayClone { + destination, + type_index: self + .layout + .repr_index(representation) + .map_err(|error| layout_error(span, error))?, + value, + span, + }, + span, + )?; + Ok(destination) + } + + fn record_array_length( + &mut self, + block: BlockId, + value: ValueId, + span: TextRange, + ) -> Result> { + let destination = self.fresh(ValueType::I32); + self.append_instruction( + block, + Instruction::ArrayLen { + destination, + value, + span, + }, + span, + )?; + Ok(destination) + } +} diff --git a/crates/psrs-backend/src/mir/lower/aggregate/record/operations/mod.rs b/crates/psrs-backend/src/mir/lower/aggregate/record/operations/mod.rs new file mode 100644 index 00000000..b71273ef --- /dev/null +++ b/crates/psrs-backend/src/mir/lower/aggregate/record/operations/mod.rs @@ -0,0 +1,354 @@ +use super::*; +use crate::cc::RecordOperation; + +mod build; + +impl FunctionLowerer<'_> { + pub(in crate::mir::lower) fn lower_record_operation( + &mut self, + mut block: BlockId, + destination: ValueId, + record: ValueId, + operation: &RecordOperation, + span: TextRange, + ) -> Result> { + let storage = self + .layout + .representation_table() + .record_storage() + .ok_or_else(|| aggregate_error(span, "record operation has no storage schema"))?; + let arrays = self.record_arrays(block, record, storage, span)?; + let mut lengths = Vec::new(); + for array in arrays { + let value = self.restore_field(block, array, span)?; + let length = self.fresh(ValueType::I32); + self.append_instruction( + block, + Instruction::ArrayLen { + destination: length, + value, + span, + }, + span, + )?; + lengths.push(length); + } + let valid = self.fresh(ValueType::Boolean); + self.append_instruction( + block, + Instruction::Primitive { + destination: valid, + op: NumericOp::I32Eq, + left: lengths[0], + right: lengths[1], + span, + }, + span, + )?; + block = self.require_record_condition(block, valid, span)?; + let zero = self.record_integer(block, 0, span)?; + let bounded = self.fresh(ValueType::Boolean); + self.append_instruction( + block, + Instruction::Primitive { + destination: bounded, + op: NumericOp::I32GeS, + left: lengths[0], + right: zero, + span, + }, + span, + )?; + block = self.require_record_condition(block, bounded, span)?; + match operation { + RecordOperation::Length => { + self.append_instruction( + block, + Instruction::Copy { + destination, + value: lengths[0], + span, + }, + span, + )?; + Ok(block) + } + RecordOperation::Read { index } | RecordOperation::LabelAt { index } => { + let valid = self.fresh(ValueType::Boolean); + self.append_instruction( + block, + Instruction::Primitive { + destination: valid, + op: NumericOp::I32GeS, + left: *index, + right: zero, + span, + }, + span, + )?; + block = self.require_record_condition(block, valid, span)?; + let valid = self.fresh(ValueType::Boolean); + self.append_instruction( + block, + Instruction::Primitive { + destination: valid, + op: NumericOp::I32LtS, + left: *index, + right: lengths[0], + span, + }, + span, + )?; + block = self.require_record_condition(block, valid, span)?; + let reading = matches!(operation, RecordOperation::Read { .. }); + let (array, repr) = if reading { + (arrays[1], storage.values()) + } else { + (arrays[0], storage.labels()) + }; + let array = self.restore_field(block, array, span)?; + self.lower_array_get(block, destination, repr, array, *index, span)?; + Ok(block) + } + RecordOperation::Find { label } => { + let label = self.persist_field(block, *label, span)?; + self.find_record_label( + block, + destination, + storage, + arrays[0], + label, + lengths[0], + span, + ) + } + RecordOperation::Update { replacements } => self.lower_record_update_operation( + block, + destination, + storage, + arrays, + lengths[0], + zero, + replacements, + span, + ), + RecordOperation::Build { .. } => Err(aggregate_error( + span, + "record build must retain its checked provider binding", + )), + } + } + + /// Build one fresh labelled store: scan every replacement label in the + /// base's runtime sequence, clone the value array once, patch the found + /// slots, and reuse the unchanged label sequence in a new header. The base + /// record is never written; a missing replacement label traps instead of + /// fabricating a slot. + #[allow(clippy::too_many_arguments)] + fn lower_record_update_operation( + &mut self, + mut block: BlockId, + destination: ValueId, + storage: RecordStorage, + arrays: [StoredValue; 2], + length: ValueId, + zero: ValueId, + replacements: &[(ValueId, ValueId)], + span: TextRange, + ) -> Result> { + let mut replacements = replacements + .iter() + .map(|(label, value)| { + Ok(( + self.persist_field(block, *label, span)?, + self.persist_field(block, *value, span)?, + )) + }) + .collect::, Vec>>()?; + let mut indices = Vec::with_capacity(replacements.len()); + for (label, _) in &replacements { + let index = self.fresh(ValueType::I32); + block = + self.find_record_label(block, index, storage, arrays[0], *label, length, span)?; + let present = self.fresh(ValueType::Boolean); + self.append_instruction( + block, + Instruction::Primitive { + destination: present, + op: NumericOp::I32GeS, + left: index, + right: zero, + span, + }, + span, + )?; + block = self.require_record_condition(block, present, span)?; + indices.push(index); + } + let base_values = self.restore_field(block, arrays[1], span)?; + let values = self.fresh( + self.layout + .value_type(&representation_shape(storage.values())) + .map_err(|error| layout_error(span, error))?, + ); + self.append_instruction( + block, + Instruction::ArrayClone { + destination: values, + type_index: self + .layout + .repr_index(storage.values()) + .map_err(|error| layout_error(span, error))?, + value: base_values, + span, + }, + span, + )?; + for (index, (_, stored)) in indices.into_iter().zip(replacements.drain(..)) { + let new_value = self.restore_field(block, stored, span)?; + self.append_instruction( + block, + Instruction::ArraySet { + type_index: self + .layout + .repr_index(storage.values()) + .map_err(|error| layout_error(span, error))?, + value: values, + index, + new_value, + span, + }, + span, + )?; + } + let labels = self.restore_field(block, arrays[0], span)?; + self.append_instruction( + block, + Instruction::StructNew { + destination, + type_index: self + .layout + .repr_index(storage.representation()) + .map_err(|error| layout_error(span, error))?, + arguments: vec![labels, values], + span, + }, + span, + )?; + Ok(block) + } + + #[allow(clippy::too_many_arguments)] + fn find_record_label( + &mut self, + block: BlockId, + destination: ValueId, + storage: RecordStorage, + labels: StoredValue, + label: StoredValue, + length: ValueId, + span: TextRange, + ) -> Result> { + let index = self.fresh(ValueType::I32); + let header = self.new_block(vec![index]); + let body = self.new_block(Vec::new()); + let found = self.new_block(Vec::new()); + let advance = self.new_block(Vec::new()); + let missing = self.new_block(Vec::new()); + let exit = self.new_block(vec![destination]); + let zero = self.record_integer(block, 0, span)?; + self.set_terminator( + block, + Terminator::Jump { + target: header, + arguments: vec![zero], + span, + }, + span, + )?; + let in_range = self.fresh(ValueType::Boolean); + self.append_instruction( + header, + Instruction::Primitive { + destination: in_range, + op: NumericOp::I32LtS, + left: index, + right: length, + span, + }, + span, + )?; + self.set_terminator( + header, + Terminator::Branch { + condition: in_range, + then_block: body, + else_block: missing, + span, + }, + span, + )?; + let array = self.restore_field(body, labels, span)?; + let candidate = self.fresh( + self.layout + .value_type(&ValueShape::String) + .map_err(|error| layout_error(span, error))?, + ); + self.lower_array_get(body, candidate, storage.labels(), array, index, span)?; + let label = self.restore_field(body, label, span)?; + let equal = self.fresh(ValueType::Boolean); + let compare = self.lower_string_eq(body, equal, candidate, label, span)?; + self.set_terminator( + compare, + Terminator::Branch { + condition: equal, + then_block: found, + else_block: advance, + span, + }, + span, + )?; + self.set_terminator( + found, + Terminator::Jump { + target: exit, + arguments: vec![index], + span, + }, + span, + )?; + let one = self.record_integer(advance, 1, span)?; + let next = self.fresh(ValueType::I32); + self.append_instruction( + advance, + Instruction::Primitive { + destination: next, + op: NumericOp::I32Add, + left: index, + right: one, + span, + }, + span, + )?; + self.set_terminator( + advance, + Terminator::Jump { + target: header, + arguments: vec![next], + span, + }, + span, + )?; + let absent = self.record_integer(missing, -1, span)?; + self.set_terminator( + missing, + Terminator::Jump { + target: exit, + arguments: vec![absent], + span, + }, + span, + )?; + Ok(exit) + } +} diff --git a/crates/psrs-backend/src/mir/lower/assignment_string/mod.rs b/crates/psrs-backend/src/mir/lower/assignment_string/mod.rs index 516d7aca..bbaa3993 100644 --- a/crates/psrs-backend/src/mir/lower/assignment_string/mod.rs +++ b/crates/psrs-backend/src/mir/lower/assignment_string/mod.rs @@ -8,6 +8,7 @@ //! decode ([DEC-16](../../../decision/DEC-16-scalar-strings-and-utf8-storage.md)). mod equality; +mod ordering; use super::aggregate::{nullable_reference_shape, reference_type, representation_shape}; use super::{BlockId, FunctionLowerer, layout_error}; diff --git a/crates/psrs-backend/src/mir/lower/assignment_string/ordering.rs b/crates/psrs-backend/src/mir/lower/assignment_string/ordering.rs new file mode 100644 index 00000000..ebe32659 --- /dev/null +++ b/crates/psrs-backend/src/mir/lower/assignment_string/ordering.rs @@ -0,0 +1,265 @@ +use super::super::{BlockId, FunctionLowerer, layout_error}; +use crate::BackendError; +use crate::mir::instruction::Instruction; +use crate::mir::{NumericOp, Terminator}; +use crate::types::{ValueId, ValueType}; +use psrs_span::TextRange; + +impl FunctionLowerer<'_> { + /// Lexicographic order of canonical UTF-8 bytes agrees with Unicode scalar + /// order. Strings entering this operation retain their checked String type. + pub(in crate::mir::lower) fn lower_string_lt( + &mut self, + current: BlockId, + destination: ValueId, + left: ValueId, + right: ValueId, + span: TextRange, + ) -> Result> { + let string_type = self.layout.string_index().ok_or_else(|| { + layout_error(span, crate::mir::layout::LayoutError::UnknownRepresentation) + })?; + let left = self.persist_field(current, left, span)?; + let right = self.persist_field(current, right, span)?; + let left_for_length = self.restore_field(current, left, span)?; + let right_for_length = self.restore_field(current, right, span)?; + let left_length = self.fresh(ValueType::I32); + let right_length = self.fresh(ValueType::I32); + self.append_instruction( + current, + Instruction::ArrayLen { + destination: left_length, + value: left_for_length, + span, + }, + span, + )?; + self.append_instruction( + current, + Instruction::ArrayLen { + destination: right_length, + value: right_for_length, + span, + }, + span, + )?; + + let index = self.fresh(ValueType::I32); + let header = self.new_block(vec![index]); + let body = self.new_block(Vec::new()); + let compare_length = self.new_block(Vec::new()); + let different = self.new_block(Vec::new()); + let less = self.new_block(Vec::new()); + let greater_or_equal = self.new_block(Vec::new()); + let advance = self.new_block(Vec::new()); + let join = self.new_block(vec![destination]); + + let zero = self.constant(current, 0, span)?; + self.set_terminator( + current, + Terminator::Jump { + target: header, + arguments: vec![zero], + span, + }, + span, + )?; + + let in_left = self.fresh(ValueType::Boolean); + self.append_instruction( + header, + Instruction::Primitive { + destination: in_left, + op: NumericOp::I32LtS, + left: index, + right: left_length, + span, + }, + span, + )?; + let in_right = self.fresh(ValueType::Boolean); + self.append_instruction( + header, + Instruction::Primitive { + destination: in_right, + op: NumericOp::I32LtS, + left: index, + right: right_length, + span, + }, + span, + )?; + let in_both = self.fresh(ValueType::Boolean); + self.append_instruction( + header, + Instruction::Primitive { + destination: in_both, + op: NumericOp::BoolAnd, + left: in_left, + right: in_right, + span, + }, + span, + )?; + self.set_terminator( + header, + Terminator::Branch { + condition: in_both, + then_block: body, + else_block: compare_length, + span, + }, + span, + )?; + let left_in_body = self.restore_field(body, left, span)?; + let right_in_body = self.restore_field(body, right, span)?; + let left_byte = self.fresh(ValueType::I32); + self.append_instruction( + body, + Instruction::ArrayGetU { + destination: left_byte, + type_index: string_type, + value: left_in_body, + index, + span, + }, + span, + )?; + let right_byte = self.fresh(ValueType::I32); + self.append_instruction( + body, + Instruction::ArrayGetU { + destination: right_byte, + type_index: string_type, + value: right_in_body, + index, + span, + }, + span, + )?; + let same = self.fresh(ValueType::Boolean); + self.append_instruction( + body, + Instruction::Primitive { + destination: same, + op: NumericOp::I32Eq, + left: left_byte, + right: right_byte, + span, + }, + span, + )?; + self.set_terminator( + body, + Terminator::Branch { + condition: same, + then_block: advance, + else_block: different, + span, + }, + span, + )?; + let byte_order = self.fresh(ValueType::Boolean); + self.append_instruction( + different, + Instruction::Primitive { + destination: byte_order, + op: NumericOp::I32LtS, + left: left_byte, + right: right_byte, + span, + }, + span, + )?; + self.set_terminator( + different, + Terminator::Branch { + condition: byte_order, + then_block: less, + else_block: greater_or_equal, + span, + }, + span, + )?; + let next = self.fresh(ValueType::I32); + let one = self.constant(advance, 1, span)?; + self.append_instruction( + advance, + Instruction::Primitive { + destination: next, + op: NumericOp::I32Add, + left: index, + right: one, + span, + }, + span, + )?; + self.set_terminator( + advance, + Terminator::Jump { + target: header, + arguments: vec![next], + span, + }, + span, + )?; + + let left_shorter = self.fresh(ValueType::Boolean); + self.append_instruction( + compare_length, + Instruction::Primitive { + destination: left_shorter, + op: NumericOp::I32LtS, + left: left_length, + right: right_length, + span, + }, + span, + )?; + self.finish_boolean(less, join, true, span)?; + self.finish_boolean(greater_or_equal, join, false, span)?; + let length_true = self.new_block(Vec::new()); + let length_false = self.new_block(Vec::new()); + self.set_terminator( + compare_length, + Terminator::Branch { + condition: left_shorter, + then_block: length_true, + else_block: length_false, + span, + }, + span, + )?; + self.finish_boolean(length_true, join, true, span)?; + self.finish_boolean(length_false, join, false, span)?; + Ok(join) + } + + fn finish_boolean( + &mut self, + block: BlockId, + join: BlockId, + value: bool, + span: TextRange, + ) -> Result<(), Vec> { + let result = self.fresh(ValueType::Boolean); + self.append_instruction( + block, + Instruction::Constant { + destination: result, + value: i32::from(value), + span, + }, + span, + )?; + self.set_terminator( + block, + Terminator::Jump { + target: join, + arguments: vec![result], + span, + }, + span, + ) + } +} diff --git a/crates/psrs-backend/src/mir/lower/assignments.rs b/crates/psrs-backend/src/mir/lower/assignments.rs index 7d8750e4..f831ecd8 100644 --- a/crates/psrs-backend/src/mir/lower/assignments.rs +++ b/crates/psrs-backend/src/mir/lower/assignments.rs @@ -49,31 +49,13 @@ impl FunctionLowerer<'_> { AssignmentKind::AggregateConvert { value, conversion, .. } => { - let helper = self - .conversion_helpers - .as_mut() - .and_then(|helpers| helpers.intern(conversion, assignment.span)); - if let Some(helper) = helper { - self.append_instruction( - current, - Instruction::Call { - contract: None, - destination: assignment.destination, - function: helper, - arguments: vec![*value], - span: assignment.span, - }, - assignment.span, - )?; - } else { - current = self.lower_aggregate_convert( - current, - *value, - assignment.destination, - conversion, - assignment.span, - )?; - } + current = self.lower_aggregate_convert( + current, + *value, + assignment.destination, + conversion, + assignment.span, + )?; } AssignmentKind::Constant(value) => self.append_instruction( current, @@ -219,6 +201,12 @@ impl FunctionLowerer<'_> { }, assignment.span, )?, + AssignmentKind::Record { .. } => { + current = self.lower_record_assignment(current, assignment)?; + } + AssignmentKind::RecordPrimitive { .. } => { + current = self.lower_record_primitive_assignment(current, assignment)?; + } AssignmentKind::ProductGet { destination, representation, diff --git a/crates/psrs-backend/src/mir/lower/conversion_helpers.rs b/crates/psrs-backend/src/mir/lower/conversion_helpers/mod.rs similarity index 85% rename from crates/psrs-backend/src/mir/lower/conversion_helpers.rs rename to crates/psrs-backend/src/mir/lower/conversion_helpers/mod.rs index 8b452693..a404488c 100644 --- a/crates/psrs-backend/src/mir/lower/conversion_helpers.rs +++ b/crates/psrs-backend/src/mir/lower/conversion_helpers/mod.rs @@ -1,3 +1,4 @@ +mod record; use crate::cc::{self, AggregateConvert, ValueConversion}; use crate::types::ValueId; use psrs_hir::{ModuleId, SymbolId}; @@ -11,6 +12,8 @@ pub(in crate::mir) struct ConversionHelpers { functions: Vec, used: HashSet, next_symbol: u32, + record_symbols: HashMap<(cc::RecordOperation, cc::ReprId), SymbolId>, + record_build_symbols: HashMap<(SymbolId, cc::RecordOperation, cc::ReprId), SymbolId>, } impl ConversionHelpers { @@ -29,6 +32,8 @@ impl ConversionHelpers { functions: Vec::new(), used, next_symbol: u32::MAX, + record_symbols: HashMap::new(), + record_build_symbols: HashMap::new(), } } @@ -43,16 +48,7 @@ impl ConversionHelpers { if let Some(symbol) = self.symbols.get(conversion) { return Some(*symbol); } - let symbol = loop { - let symbol = SymbolId::new(ModuleId::INTRINSICS, self.next_symbol); - self.next_symbol = self - .next_symbol - .checked_sub(1) - .expect("conversion helper symbol space exhausted"); - if self.used.insert(symbol) { - break symbol; - } - }; + let symbol = self.allocate_symbol(); let source = ValueId(0); let destination = ValueId(1); self.functions.push(cc::Function { @@ -86,6 +82,19 @@ impl ConversionHelpers { Some(symbol) } + fn allocate_symbol(&mut self) -> SymbolId { + loop { + let symbol = SymbolId::new(ModuleId::INTRINSICS, self.next_symbol); + self.next_symbol = self + .next_symbol + .checked_sub(1) + .expect("conversion helper symbol space exhausted"); + if self.used.insert(symbol) { + break symbol; + } + } + } + pub(in crate::mir) fn into_functions(self) -> Vec { self.functions } @@ -93,7 +102,14 @@ impl ConversionHelpers { fn rebuilds_aggregate(plan: &ValueConversion) -> bool { match plan { - ValueConversion::ArrayMap { .. } | ValueConversion::ProductMap { .. } => true, + ValueConversion::ArrayMap { .. } + | ValueConversion::ProductMap { .. } + | ValueConversion::RecordUnpack { .. } => true, + // Fixed packing of already erased fields emits only projections and + // allocations. It needs no helper to isolate internal control flow. + ValueConversion::RecordPack { fields, .. } => fields + .iter() + .any(|field| !matches!(field, ValueConversion::Identity)), ValueConversion::Sequence(steps) => steps.iter().any(rebuilds_aggregate), _ => false, } diff --git a/crates/psrs-backend/src/mir/lower/conversion_helpers/record.rs b/crates/psrs-backend/src/mir/lower/conversion_helpers/record.rs new file mode 100644 index 00000000..fe9a556a --- /dev/null +++ b/crates/psrs-backend/src/mir/lower/conversion_helpers/record.rs @@ -0,0 +1,115 @@ +use super::*; +use crate::cc::{RecordOperation, RecordStorage}; + +impl ConversionHelpers { + pub(in crate::mir::lower) fn intern_record_build( + &mut self, + provider: SymbolId, + operation: &RecordOperation, + storage: RecordStorage, + span: TextRange, + ) -> SymbolId { + let count = operation.arguments(storage).len(); + let operation = operation + .clone() + .with_arguments((0..count).map(|index| ValueId(index as u32)).collect()); + let key = (provider, operation.clone(), storage.representation()); + if let Some(symbol) = self.record_build_symbols.get(&key) { + return *symbol; + } + let arguments = operation.arguments(storage); + let result = ValueId(count as u32); + let symbol = self.allocate_symbol(); + let mut values = arguments + .iter() + .map(|(id, ty)| cc::ValueDecl { id: *id, ty: *ty }) + .collect::>(); + let result_type = operation.result_shape(storage); + values.push(cc::ValueDecl { + id: result, + ty: result_type, + }); + self.functions.push(cc::Function { + symbol, + name: format!("record_build_{}", self.record_build_symbols.len()), + parameters: arguments.iter().map(|(id, _)| *id).collect(), + values, + assignments: vec![cc::Assignment { + destination: result, + kind: cc::AssignmentKind::RecordPrimitive { + provider, + record: None, + operation, + }, + span, + }], + result, + result_type, + span, + }); + self.record_build_symbols.insert(key, symbol); + symbol + } + + pub(in crate::mir::lower) fn intern_record( + &mut self, + operation: &RecordOperation, + storage: RecordStorage, + span: TextRange, + ) -> SymbolId { + // Abstract the concrete call-site operands into one shared helper per + // operation arity: record is parameter 0, each operand takes the next + // id, and the result follows them. + let count = operation.arguments(storage).len(); + let operation = operation.clone().with_arguments( + (0..count) + .map(|index| ValueId(index as u32 + 1)) + .collect::>(), + ); + let arguments = operation.arguments(storage); + let result = ValueId(count as u32 + 1); + let key = (operation.clone(), storage.representation()); + if let Some(symbol) = self.record_symbols.get(&key) { + return *symbol; + } + let symbol = self.allocate_symbol(); + let record = ValueId(0); + let mut parameters = vec![record]; + let mut values = vec![cc::ValueDecl { + id: record, + ty: cc::ValueShape::Reference(cc::Reference { + nullable: false, + heap: cc::RefShape::Repr(storage.representation()), + }), + }]; + for (id, ty) in arguments { + parameters.push(id); + values.push(cc::ValueDecl { id, ty }); + } + let result_type = operation.result_shape(storage); + values.push(cc::ValueDecl { + id: result, + ty: result_type, + }); + self.functions.push(cc::Function { + symbol, + name: format!("record_op_{}", self.record_symbols.len()), + parameters, + values, + assignments: vec![cc::Assignment { + destination: result, + kind: cc::AssignmentKind::Record { + destination: result, + record, + operation, + }, + span, + }], + result, + result_type, + span, + }); + self.record_symbols.insert(key, symbol); + symbol + } +} diff --git a/crates/psrs-backend/src/mir/lower/mod.rs b/crates/psrs-backend/src/mir/lower/mod.rs index b43a18d4..8c0db59c 100644 --- a/crates/psrs-backend/src/mir/lower/mod.rs +++ b/crates/psrs-backend/src/mir/lower/mod.rs @@ -17,6 +17,7 @@ mod assignment_array; mod assignment_string; mod assignments; mod conversion_helpers; +mod record_call; mod runtime_call; mod storage_call; pub(in crate::mir) mod tail; @@ -48,6 +49,7 @@ pub(super) fn lower_function( id: FunctionId, wit_imports: &HashMap, runtime_imports: &HashMap, + record_primitives: &HashMap, layout: &PlannedLayout, conversion_helpers: Option<&mut ConversionHelpers>, literals: Option<&mut StringLiterals>, @@ -84,6 +86,7 @@ pub(super) fn lower_function( .map_or(0, |max| max + 1), wit_imports, runtime_imports, + record_primitives, layout, conversion_helpers, literals, @@ -128,6 +131,7 @@ pub(super) struct FunctionLowerer<'a> { next_value: u32, wit_imports: &'a HashMap, runtime_imports: &'a HashMap, + record_primitives: &'a HashMap, pub(in crate::mir) layout: &'a PlannedLayout, conversion_helpers: Option<&'a mut ConversionHelpers>, literals: Option<&'a mut StringLiterals>, diff --git a/crates/psrs-backend/src/mir/lower/record_call.rs b/crates/psrs-backend/src/mir/lower/record_call.rs new file mode 100644 index 00000000..a30d6257 --- /dev/null +++ b/crates/psrs-backend/src/mir/lower/record_call.rs @@ -0,0 +1,132 @@ +use super::*; +use crate::cc::{Assignment, AssignmentKind}; + +impl FunctionLowerer<'_> { + pub(super) fn lower_record_primitive_assignment( + &mut self, + block: BlockId, + assignment: &Assignment, + ) -> Result> { + let AssignmentKind::RecordPrimitive { + provider, + record, + operation, + } = &assignment.kind + else { + unreachable!("record primitive assignment lowering dispatch"); + }; + if !self.record_primitives.contains_key(provider) { + return Err(vec![ + BackendError::invalid_ir( + "P9 runtime projection", + assignment.span, + "record primitive provider was not checked before lowering", + ) + .with_module(provider.module), + ]); + } + let storage = self + .layout + .representation_table() + .record_storage() + .ok_or_else(|| { + vec![BackendError::invalid_ir( + "P9 runtime projection", + assignment.span, + "record primitive has no shared storage owner", + )] + })?; + if let Some(helpers) = self.conversion_helpers.as_mut() { + let function = if matches!(operation, crate::cc::RecordOperation::Build { .. }) { + helpers.intern_record_build(*provider, operation, storage, assignment.span) + } else { + helpers.intern_record(operation, storage, assignment.span) + }; + let mut arguments = Vec::new(); + if let Some(record) = record { + arguments.push(*record); + } + arguments.extend(operation.arguments(storage).iter().map(|(value, _)| *value)); + self.append_instruction( + block, + Instruction::Call { + contract: None, + destination: assignment.destination, + function, + arguments, + span: assignment.span, + }, + assignment.span, + )?; + Ok(block) + } else if let crate::cc::RecordOperation::Build { labels, values } = operation { + self.lower_record_build_operation( + block, + assignment.destination, + *labels, + *values, + assignment.span, + ) + } else { + let record = record.expect("non-build record operation has a record operand"); + self.lower_record_operation( + block, + assignment.destination, + record, + operation, + assignment.span, + ) + } + } + + pub(super) fn lower_record_assignment( + &mut self, + mut current: BlockId, + assignment: &Assignment, + ) -> Result> { + let AssignmentKind::Record { + destination, + record, + operation, + } = &assignment.kind + else { + unreachable!("record assignment lowering dispatch"); + }; + let storage = self + .layout + .representation_table() + .record_storage() + .ok_or_else(|| { + vec![BackendError::invalid_ir( + "P9 MIR lowering", + assignment.span, + "record operation has no registered storage schema", + )] + })?; + if let Some(helpers) = self.conversion_helpers.as_mut() { + let function = helpers.intern_record(operation, storage, assignment.span); + let mut arguments = vec![*record]; + arguments.extend(operation.arguments(storage).iter().map(|(value, _)| *value)); + self.append_instruction( + current, + Instruction::Call { + contract: None, + destination: *destination, + function, + arguments, + span: assignment.span, + }, + assignment.span, + )?; + } else { + current = self.lower_record_operation( + current, + *destination, + *record, + operation, + assignment.span, + )?; + } + Ok(current) + } +} diff --git a/crates/psrs-backend/src/mir/lower/wit_call.rs b/crates/psrs-backend/src/mir/lower/wit_call.rs index 94a4ceec..17829ddb 100644 --- a/crates/psrs-backend/src/mir/lower/wit_call.rs +++ b/crates/psrs-backend/src/mir/lower/wit_call.rs @@ -36,6 +36,7 @@ impl FunctionLowerer<'_> { next_value: self.next_value, wit_imports: self.wit_imports, runtime_imports: self.runtime_imports, + record_primitives: self.record_primitives, layout: self.layout, conversion_helpers: self.conversion_helpers.as_deref_mut(), literals: self.literals.as_deref_mut(), diff --git a/crates/psrs-backend/src/mir/lower/wit_tests.rs b/crates/psrs-backend/src/mir/lower/wit_tests.rs index f18b6885..066310d7 100644 --- a/crates/psrs-backend/src/mir/lower/wit_tests.rs +++ b/crates/psrs-backend/src/mir/lower/wit_tests.rs @@ -39,6 +39,7 @@ fn gc_wit_record_projection_uses_the_planned_product_type() { next_value: 1, wit_imports: &HashMap::new(), runtime_imports: &HashMap::new(), + record_primitives: &HashMap::new(), layout: &layout, literals: None, }; diff --git a/crates/psrs-backend/src/mir/mod.rs b/crates/psrs-backend/src/mir/mod.rs index 6577b0ec..e32796c5 100644 --- a/crates/psrs-backend/src/mir/mod.rs +++ b/crates/psrs-backend/src/mir/mod.rs @@ -306,6 +306,7 @@ fn lower_module_after_binding_validation( FunctionId(id as u32), &wit_imports, &runtime.imports, + &runtime.record_primitives, &layout, Some(&mut conversion_helpers), Some(&mut literals), @@ -326,6 +327,7 @@ fn lower_module_after_binding_validation( FunctionId(first_helper_id + offset as u32), &wit_imports, &runtime.imports, + &runtime.record_primitives, &layout, None, Some(&mut literals), diff --git a/crates/psrs-backend/src/mir/reachable/assignments.rs b/crates/psrs-backend/src/mir/reachable/assignments.rs index 37d65f63..44fbc9d4 100644 --- a/crates/psrs-backend/src/mir/reachable/assignments.rs +++ b/crates/psrs-backend/src/mir/reachable/assignments.rs @@ -20,6 +20,10 @@ pub(super) fn add_assignments( ) { for assignment in assignments { match &assignment.kind { + AssignmentKind::Record { .. } | AssignmentKind::RecordPrimitive { .. } => { + // The record value shape reaches the registered header and its + // two array children through ordinary representation traversal. + } AssignmentKind::DirectCall { function, .. } => { direct_calls.insert(*function); } @@ -190,6 +194,18 @@ pub(super) fn add_conversion( target, fields, .. + } + | ValueConversion::RecordPack { + source, + target, + fields, + .. + } + | ValueConversion::RecordUnpack { + source, + target, + fields, + .. } => { add_representation(*source, representations, representation_work); add_representation(*target, representations, representation_work); diff --git a/crates/psrs-backend/src/mir/reachable/projection.rs b/crates/psrs-backend/src/mir/reachable/projection.rs index 760a65c8..6e43bc5f 100644 --- a/crates/psrs-backend/src/mir/reachable/projection.rs +++ b/crates/psrs-backend/src/mir/reachable/projection.rs @@ -8,6 +8,7 @@ use psrs_span::TextRange; pub(super) fn has_string(layout: &GuestLayout) -> bool { match layout { + GuestLayout::RecordStorage { .. } => true, GuestLayout::Scalar { shape } | GuestLayout::Boxed { shape } => { *shape == ValueShape::String } @@ -33,6 +34,29 @@ pub(super) fn add_projection( signature_work: &mut Vec, ) -> Result<(), LayoutError> { match layout { + GuestLayout::RecordStorage { + storage, + product, + fields, + .. + } => { + add_representation( + storage.representation(), + representations, + representation_work, + ); + add_representation(*product, representations, representation_work); + for field in fields { + add_field( + field, + table, + representations, + signatures, + representation_work, + signature_work, + )?; + } + } GuestLayout::Scalar { shape } | GuestLayout::Boxed { shape } => add_value( shape, representations, diff --git a/crates/psrs-backend/src/mir/runtime/context.rs b/crates/psrs-backend/src/mir/runtime/context.rs index 74bc598a..eb8eae40 100644 --- a/crates/psrs-backend/src/mir/runtime/context.rs +++ b/crates/psrs-backend/src/mir/runtime/context.rs @@ -5,6 +5,7 @@ use std::collections::HashMap; pub(in crate::mir) struct RuntimeContext { pub(in crate::mir) imports: HashMap, + pub(in crate::mir) record_primitives: HashMap, } impl RuntimeContext { @@ -21,11 +22,123 @@ impl RuntimeContext { )] })?; let mut imports = HashMap::new(); + let mut record_primitives = HashMap::new(); for binding in bindings { + if binding.module == psrs_runtime::RECORD_STORAGE_MODULE { + let primitive = psrs_runtime::RecordPrimitive::from_export(&binding.function) + .ok_or_else(|| binding_error(binding, "unknown record-storage export"))?; + let external = physical + .externals + .iter() + .find(|external| external.symbol == binding.symbol) + .and_then(|external| external.signature.as_ref()) + .ok_or_else(|| binding_error(binding, "record provider has no CC signature"))?; + let canonical = crate::bindings::runtime_cc_signature( + binding, + external, + &physical.representations, + )?; + if &canonical != external { + return Err(binding_error( + binding, + "record provider CC signature is not canonical", + )); + } + record_primitives.insert(binding.symbol, primitive); + if live.direct_calls.contains(&binding.symbol) { + return Err(binding_error( + binding, + "record provider bypasses its checked CC operation", + )); + } + continue; + } if live.direct_calls.contains(&binding.symbol) { imports.insert(binding.symbol, plan_import(binding, physical, layout)?); } } - Ok(Self { imports }) + validate_record_operations(physical, bindings, &record_primitives)?; + Ok(Self { + imports, + record_primitives, + }) + } +} + +fn binding_error(binding: &RuntimeBinding, message: &str) -> Vec { + vec![ + BackendError::invalid_ir("P9 runtime projection", binding.span, message) + .with_module(binding.source_module), + ] +} + +fn validate_record_operations( + module: &cc::Module, + bindings: &[RuntimeBinding], + providers: &HashMap, +) -> Result<(), Vec> { + let metadata = bindings + .iter() + .map(|binding| (binding.symbol, binding)) + .collect::>(); + for function in &module.functions { + validate_assignments(&function.assignments, providers, &metadata)?; + } + Ok(()) +} + +fn validate_assignments( + assignments: &[cc::Assignment], + providers: &HashMap, + metadata: &HashMap, +) -> Result<(), Vec> { + for assignment in assignments { + match &assignment.kind { + cc::AssignmentKind::RecordPrimitive { + provider, + record, + operation, + } => { + let Some(primitive) = providers.get(provider) else { + let message = "CC record operation has no checked source provider"; + let mut error = + BackendError::invalid_ir("P9 runtime projection", assignment.span, message); + if let Some(binding) = metadata.get(provider) { + error = error.with_module(binding.source_module); + } + return Err(vec![error]); + }; + let valid = crate::bindings::operation_matches(*primitive, *record, operation); + if !valid { + let message = "CC record operation disagrees with its checked provider export"; + let mut error = + BackendError::invalid_ir("P9 runtime projection", assignment.span, message); + if let Some(binding) = metadata.get(provider) { + error = error.with_module(binding.source_module); + } + return Err(vec![error]); + } + } + cc::AssignmentKind::If { + then_assignments, + else_assignments, + .. + } => { + validate_assignments(then_assignments, providers, metadata)?; + validate_assignments(else_assignments, providers, metadata)?; + } + cc::AssignmentKind::TagSwitch { + cases, + default_assignments, + .. + } => { + for case in cases { + validate_assignments(&case.assignments, providers, metadata)?; + } + validate_assignments(default_assignments, providers, metadata)?; + } + _ => {} + } } + Ok(()) } diff --git a/crates/psrs-backend/src/mir/verify/mod.rs b/crates/psrs-backend/src/mir/verify/mod.rs index 1985c54e..c6cf74c2 100644 --- a/crates/psrs-backend/src/mir/verify/mod.rs +++ b/crates/psrs-backend/src/mir/verify/mod.rs @@ -14,6 +14,7 @@ mod capability; mod captures; mod function; mod instruction; +mod records; mod subtype; mod util; mod variants; @@ -69,6 +70,7 @@ pub fn verify_module(module: &Module) -> Result<(), Vec> { } let defined_types = defined_type_count(module); let defined = defined_types_list(module); + records::verify(module, &defined, &mut errors); captures::verify(module, &defined, &mut errors); let mut function_ids = HashSet::new(); for (position, function) in module.functions.iter().enumerate() { diff --git a/crates/psrs-backend/src/mir/verify/records.rs b/crates/psrs-backend/src/mir/verify/records.rs new file mode 100644 index 00000000..670c1ad9 --- /dev/null +++ b/crates/psrs-backend/src/mir/verify/records.rs @@ -0,0 +1,182 @@ +//! Discharge record storage views against their owner and actual MIR GC types. +use super::*; +use crate::cc::{Field, GuestLayout, Representation}; +use crate::types::{FieldType, RefType}; + +pub(super) fn verify(module: &Module, defined: &[&DefinedType], errors: &mut Vec) { + for instruction in module + .functions + .iter() + .flat_map(|function| &function.blocks) + .flat_map(|block| &block.instructions) + { + if let Instruction::ListCopy { + element_guest, + direction, + struct_type, + span, + .. + } = instruction + { + if let GuestLayout::RecordStorage { storage, .. } = element_guest + && *direction != crate::mir::ListDirection::Free + && module + .layout + .as_ref() + .and_then(|layout| layout.repr_index(storage.representation()).ok()) + != Some(*struct_type) + { + errors.extend(mir_error( + *span, + "MIR record list header disagrees with its owner layout", + )); + } + if let Err(found) = verify_guest(module, defined, element_guest, *span) { + errors.extend(found); + } + } + } +} + +fn verify_guest( + module: &Module, + defined: &[&DefinedType], + guest: &GuestLayout, + span: TextRange, +) -> Result<(), Vec> { + match guest { + GuestLayout::RecordStorage { + storage, + product, + labels, + fields, + } => { + let layout = module + .layout + .as_ref() + .ok_or_else(|| mir_error(span, "MIR record storage view has no owner layout"))?; + let table = layout.representation_table(); + if table.record_storage() != Some(*storage) + || table.verify_record_storage().is_err() + || table.product_labels(*product) != Some(labels.as_slice()) + || table.representation(*product) + != Some(&Representation::Product { + fields: fields.iter().map(|field| field.stored).collect(), + }) + || labels.len() != fields.len() + || labels.windows(2).any(|pair| pair[0] >= pair[1]) + { + return Err(mir_error( + span, + "MIR record storage view disagrees with its representation owner", + )); + } + let index = |repr| { + layout.repr_index(repr).map_err(|_| { + mir_error( + span, + "MIR record storage schema has an unresolved representation", + ) + }) + }; + let header = index(storage.representation())?; + let keys = index(storage.labels())?; + let values = index(storage.values())?; + let string = layout + .string_index() + .ok_or_else(|| mir_error(span, "MIR record storage labels have no String type"))?; + let reference = |index, nullable| { + StorageType::Ref(RefType { + nullable, + heap: HeapType::Index(index), + }) + }; + let expected_header = CompositeType::Struct(vec![ + FieldType { + storage: reference(keys, false), + mutable: false, + }, + FieldType { + storage: reference(values, false), + mutable: false, + }, + ]); + let expected_keys = CompositeType::Array(FieldType { + storage: reference(string, true), + mutable: true, + }); + let expected_values = CompositeType::Array(FieldType { + storage: StorageType::Ref(RefType { + nullable: true, + heap: HeapType::Eq, + }), + mutable: true, + }); + let expected_string = CompositeType::Array(FieldType { + storage: StorageType::I8, + mutable: true, + }); + for (id, expected) in [ + (header, expected_header), + (keys, expected_keys), + (values, expected_values), + (string, expected_string), + ] { + if defined.get(id.0 as usize).map(|ty| &ty.composite) != Some(&expected) { + return Err(mir_error( + span, + "MIR record storage schema disagrees with its physical GC definitions", + )); + } + } + for field in fields { + verify_field( + module, + defined, + &Field { + value: field.value.clone(), + stored: crate::cc::payload::erased_shape(), + }, + span, + )?; + } + } + GuestLayout::Product { fields, .. } => { + for field in fields { + verify_field(module, defined, field, span)?; + } + } + GuestLayout::Variant { cases, .. } => { + for field in cases.iter().flat_map(|case| &case.fields) { + verify_field(module, defined, field, span)?; + } + } + GuestLayout::Array { element, .. } => verify_field(module, defined, element, span)?, + GuestLayout::Scalar { .. } | GuestLayout::Boxed { .. } => {} + } + Ok(()) +} + +fn verify_field( + module: &Module, + defined: &[&DefinedType], + field: &Field, + span: TextRange, +) -> Result<(), Vec> { + if field.stored == crate::cc::payload::erased_shape() + && matches!( + field.value, + GuestLayout::Product { .. } | GuestLayout::Array { .. } + ) + { + let layout = module + .layout + .as_ref() + .ok_or_else(|| mir_error(span, "MIR erased record/array view has no owner layout"))?; + let stored = crate::cc::payload::stored_guest_layout(field, layout.representation_table()) + .ok_or_else(|| mir_error(span, "MIR aggregate field has no checked storage view"))?; + verify_guest(module, defined, &stored, span) + } else { + verify_guest(module, defined, &field.value, span) + } +} diff --git a/crates/psrs-backend/src/mir/wit/aggregate/collections.rs b/crates/psrs-backend/src/mir/wit/aggregate/collections.rs index a4674089..b15bb52b 100644 --- a/crates/psrs-backend/src/mir/wit/aggregate/collections.rs +++ b/crates/psrs-backend/src/mir/wit/aggregate/collections.rs @@ -118,7 +118,7 @@ pub(super) fn read_flags( pub(super) fn read_value_list( lowerer: &mut L, representation: crate::cc::ReprId, - element_shape: ValueShape, + element_field: &crate::cc::Field, element: &CanonicalType, address: ValueId, offset: u32, @@ -129,7 +129,7 @@ pub(super) fn read_value_list( .wit_repr_index(representation) .ok_or_else(|| unsupported(span))?; let element_guest = lowerer - .wit_guest_layout(element_shape) + .wit_stored_guest_layout(element_field) .ok_or_else(|| unsupported(span))?; let struct_type = element_struct_type(lowerer, &element_guest, span)?; let destination = lowerer.fresh_wit_value(ValueType::Ref(RefType { @@ -165,7 +165,7 @@ pub(super) fn read_value_list( pub(super) fn read_fixed_list( lowerer: &mut L, representation: crate::cc::ReprId, - element_shape: ValueShape, + element_field: &crate::cc::Field, element: &CanonicalType, length: u32, address: ValueId, @@ -177,7 +177,7 @@ pub(super) fn read_fixed_list( .wit_repr_index(representation) .ok_or_else(|| unsupported(span))?; let element_guest = lowerer - .wit_guest_layout(element_shape) + .wit_stored_guest_layout(element_field) .ok_or_else(|| unsupported(span))?; let struct_type = element_struct_type(lowerer, &element_guest, span)?; let destination = lowerer.fresh_wit_value(ValueType::Ref(RefType { @@ -247,6 +247,9 @@ pub(super) fn element_struct_type( span: TextRange, ) -> Result> { match element_guest { + GuestLayout::RecordStorage { storage, .. } => lowerer + .wit_repr_index(storage.representation()) + .ok_or_else(|| unsupported(span)), GuestLayout::Product { repr, .. } => lowerer .wit_repr_index(*repr) .ok_or_else(|| unsupported(span)), diff --git a/crates/psrs-backend/src/mir/wit/aggregate/decode.rs b/crates/psrs-backend/src/mir/wit/aggregate/decode.rs index 8dbf52df..792642f4 100644 --- a/crates/psrs-backend/src/mir/wit/aggregate/decode.rs +++ b/crates/psrs-backend/src/mir/wit/aggregate/decode.rs @@ -74,6 +74,24 @@ fn build_concrete( span: TextRange, ) -> Result<(ValueId, BlockId), Vec> { match guest { + GuestLayout::RecordStorage { + product, + labels, + fields, + .. + } => { + let logical = GuestLayout::Product { + repr: *product, + labels: labels.clone(), + fields: fields.clone(), + }; + let (value, block) = + build_concrete(lowerer, kind, &logical, address, offset, block, span)?; + let (block, value) = + lowerer.wit_payload_conversion(block, value, logical.shape(), true, span)?; + let value = cast_reference(lowerer, value, &guest.shape(), block, span)?; + Ok((value, block)) + } GuestLayout::Variant { repr, cases } if is_variant_kind(kind) => { build_variant(lowerer, kind, *repr, cases, address, offset, block, span) } @@ -96,7 +114,7 @@ fn build_concrete( read_value_list( lowerer, *repr, - element.stored, + element, wit_element, address, offset, @@ -112,7 +130,7 @@ fn build_concrete( read_fixed_list( lowerer, *repr, - element.stored, + element, wit_element, *length, address, diff --git a/crates/psrs-backend/src/mir/wit/function_lowerer.rs b/crates/psrs-backend/src/mir/wit/function_lowerer.rs index 3e157c0d..4c6daa88 100644 --- a/crates/psrs-backend/src/mir/wit/function_lowerer.rs +++ b/crates/psrs-backend/src/mir/wit/function_lowerer.rs @@ -200,8 +200,29 @@ impl WitCallLowerer for FunctionLowerer<'_> { heap: crate::cc::RefShape::Erased, }) }; - let (block, value, _) = self.lower_value_conversion(block, value, source, &plan, span)?; - Ok((block, value)) + let destination_shape = plan.output_shape(source); + let destination_type = self + .resolved_value_type(&destination_shape) + .ok_or_else(|| { + vec![BackendError::invalid_ir( + "P9 MIR lowering", + span, + "payload conversion result has no target layout", + )] + })?; + let destination = self.fresh(destination_type); + let block = self.lower_aggregate_convert( + block, + value, + destination, + &crate::cc::AggregateConvert { + source, + destination: destination_shape, + plan, + }, + span, + )?; + Ok((block, destination)) } fn wit_repr_index(&self, repr: crate::cc::ReprId) -> Option { diff --git a/crates/psrs-backend/src/mir/wit/lists/mod.rs b/crates/psrs-backend/src/mir/wit/lists/mod.rs index 914e5852..3514e6e1 100644 --- a/crates/psrs-backend/src/mir/wit/lists/mod.rs +++ b/crates/psrs-backend/src/mir/wit/lists/mod.rs @@ -294,6 +294,9 @@ pub(super) fn element_struct_type( span: TextRange, ) -> Result> { match element_guest { + GuestLayout::RecordStorage { storage, .. } => lowerer + .wit_repr_index(storage.representation()) + .ok_or_else(|| unsupported_list(span)), GuestLayout::Product { repr, .. } | GuestLayout::Variant { repr, .. } => lowerer .wit_repr_index(*repr) .ok_or_else(|| unsupported_list(span)), diff --git a/crates/psrs-backend/src/mir/wit/parameters/mod.rs b/crates/psrs-backend/src/mir/wit/parameters/mod.rs index bd7e66d2..1a9ff12f 100644 --- a/crates/psrs-backend/src/mir/wit/parameters/mod.rs +++ b/crates/psrs-backend/src/mir/wit/parameters/mod.rs @@ -87,6 +87,31 @@ pub(super) fn lower_parameter( current: BlockId, span: TextRange, ) -> Result> { + if let Some(GuestLayout::RecordStorage { + product, + labels, + fields, + .. + }) = guest + { + let logical = GuestLayout::Product { + repr: *product, + labels: labels.clone(), + fields: fields.clone(), + }; + let (current, argument) = + lowerer.wit_payload_conversion(current, argument, logical.shape(), false, span)?; + return lower_parameter( + lowerer, + argument, + Some(&logical), + ty, + flat, + frees, + current, + span, + ); + } match ty { CanonicalType::Int { width: 32, .. } | CanonicalType::Bool diff --git a/crates/psrs-backend/src/mir/wit/tests/primitive.rs b/crates/psrs-backend/src/mir/wit/tests/primitive.rs index 6b2e9ddd..85a5dc53 100644 --- a/crates/psrs-backend/src/mir/wit/tests/primitive.rs +++ b/crates/psrs-backend/src/mir/wit/tests/primitive.rs @@ -33,6 +33,7 @@ fn validate( external_types: Vec::new(), types: Vec::new(), newtype_ids: Vec::new(), + ordinary_data_ids: Vec::new(), opaque_ids: Vec::new(), callable_types: Vec::new(), constructors: Vec::new(), @@ -141,6 +142,7 @@ fn option_string_validates_and_lowers_as_a_discriminant_and_string() { external_types: Vec::new(), types: Vec::new(), newtype_ids: Vec::new(), + ordinary_data_ids: Vec::new(), opaque_ids: Vec::new(), callable_types: Vec::new(), constructors: Vec::new(), @@ -190,6 +192,7 @@ fn option_string_validates_and_lowers_as_a_discriminant_and_string() { external_types: Vec::new(), types: Vec::new(), newtype_ids: Vec::new(), + ordinary_data_ids: Vec::new(), opaque_ids: Vec::new(), callable_types: Vec::new(), constructors: Vec::new(), diff --git a/crates/psrs-backend/src/wasm/lower/structure/lists/aggregate.rs b/crates/psrs-backend/src/wasm/lower/structure/lists/aggregate.rs index b49758c4..75de7458 100644 --- a/crates/psrs-backend/src/wasm/lower/structure/lists/aggregate.rs +++ b/crates/psrs-backend/src/wasm/lower/structure/lists/aggregate.rs @@ -42,6 +42,25 @@ impl Structurer<'_> { return self.emit_byte_store(body, context, offset, guest, path, span); } match (canonical, guest) { + ( + CanonicalType::Record(fields), + GuestLayout::RecordStorage { + storage, + labels, + fields: shapes, + .. + }, + ) => self.emit_record_storage_store( + body, context, offset, fields, *storage, labels, shapes, path, span, + ), + ( + CanonicalType::Flags(names), + GuestLayout::RecordStorage { + storage, labels, .. + }, + ) => self.emit_record_storage_flags_store( + body, context, offset, names, *storage, labels, path, span, + ), ( CanonicalType::Record(fields), GuestLayout::Product { @@ -62,16 +81,7 @@ impl Structurer<'_> { self.emit_variant_store(body, context, offset, canonical, *repr, cases, path, span) } (CanonicalType::List(inner), GuestLayout::Array { repr, element }) => self - .emit_nested_list_store( - body, - context, - offset, - inner, - *repr, - element.stored, - path, - span, - ), + .emit_nested_list_store(body, context, offset, inner, *repr, element, path, span), ( CanonicalType::FixedList { element, length }, GuestLayout::Array { @@ -79,15 +89,7 @@ impl Structurer<'_> { element: field, }, ) => self.emit_fixed_list_store( - body, - context, - offset, - element, - *length, - *repr, - field.stored, - path, - span, + body, context, offset, element, *length, *repr, field, path, span, ), _ => self.emit_scalar_store(body, context, offset, canonical, guest, path, span), } @@ -107,6 +109,25 @@ impl Structurer<'_> { return self.emit_string_node_load(body, context, offset, span); } match (canonical, guest) { + ( + CanonicalType::Record(fields), + GuestLayout::RecordStorage { + storage, + labels, + fields: shapes, + .. + }, + ) => self.emit_record_storage_load( + body, context, offset, fields, *storage, labels, shapes, span, + ), + ( + CanonicalType::Flags(names), + GuestLayout::RecordStorage { + storage, labels, .. + }, + ) => self.emit_record_storage_flags_load( + body, context, offset, names, *storage, labels, span, + ), ( CanonicalType::Record(fields), GuestLayout::Product { @@ -122,24 +143,17 @@ impl Structurer<'_> { CanonicalType::Option(_) | CanonicalType::Result { .. } | CanonicalType::Variant(_), GuestLayout::Variant { repr, cases }, ) => self.emit_variant_load(body, context, offset, canonical, *repr, cases, span), - (CanonicalType::List(inner), GuestLayout::Array { repr, element }) => self - .emit_nested_list_load(body, context, offset, inner, *repr, element.stored, span), + (CanonicalType::List(inner), GuestLayout::Array { repr, element }) => { + self.emit_nested_list_load(body, context, offset, inner, *repr, element, span) + } ( CanonicalType::FixedList { element, length }, GuestLayout::Array { repr, element: field, }, - ) => self.emit_fixed_list_load( - body, - context, - offset, - element, - *length, - *repr, - field.stored, - span, - ), + ) => self + .emit_fixed_list_load(body, context, offset, element, *length, *repr, field, span), _ => self.emit_scalar_load(body, context, offset, canonical, guest, span), } } @@ -197,6 +211,23 @@ impl Structurer<'_> { self.emit_project(body, context, rest, span)?; body.push(Op::Leaf(Instruction::RefAsNonNull)); match last { + Projection::RecordField { + header, + values, + field, + } => { + body.push(Op::Leaf(Instruction::RefCastNonNull( + crate::wasm::convert::heap_type(crate::types::HeapType::Index( + crate::types::DefinedTypeId(*header), + )), + ))); + body.push(Op::Leaf(Instruction::StructGet { + struct_type_index: *header, + field_index: 1, + })); + body.push(Op::Leaf(Instruction::I32Const(*field as i32))); + body.push(Op::Leaf(Instruction::ArrayGet(*values))); + } Projection::Field { ty, field } => { body.push(Op::Leaf(Instruction::StructGet { struct_type_index: *ty, diff --git a/crates/psrs-backend/src/wasm/lower/structure/lists/mod.rs b/crates/psrs-backend/src/wasm/lower/structure/lists/mod.rs index a17614a7..f8398fdb 100644 --- a/crates/psrs-backend/src/wasm/lower/structure/lists/mod.rs +++ b/crates/psrs-backend/src/wasm/lower/structure/lists/mod.rs @@ -51,8 +51,19 @@ pub(super) struct ListLoop { /// element to the concrete case struct. #[derive(Clone, Copy)] pub(super) enum Projection { - Field { ty: u32, field: u32 }, - Case { ty: u32, field: u32 }, + Field { + ty: u32, + field: u32, + }, + Case { + ty: u32, + field: u32, + }, + RecordField { + header: u32, + values: u32, + field: u32, + }, } impl Structurer<'_> { diff --git a/crates/psrs-backend/src/wasm/lower/structure/lists/nested.rs b/crates/psrs-backend/src/wasm/lower/structure/lists/nested.rs index dadbd1ee..4061cc00 100644 --- a/crates/psrs-backend/src/wasm/lower/structure/lists/nested.rs +++ b/crates/psrs-backend/src/wasm/lower/structure/lists/nested.rs @@ -10,7 +10,7 @@ use super::{ListLoop, LocalRef, Structurer}; use crate::BackendError; use crate::abi; use crate::abi::canonical::CanonicalType; -use crate::cc::ValueShape; +use crate::cc::Field; use crate::types::HeapType; use crate::wasm::{Body, Op}; use psrs_span::TextRange; @@ -27,11 +27,11 @@ impl Structurer<'_> { offset: u32, inner: &CanonicalType, repr: crate::cc::ReprId, - element_shape: ValueShape, + element_field: &Field, path: &[super::Projection], span: TextRange, ) -> Result<(), Vec> { - let inner_guest = self.resolve_guest(element_shape, span)?; + let inner_guest = self.resolve_field(element_field, span)?; let inner_array_type = self.repr_index(repr, span)?; let layout = abi::canonical::size_align(inner); let inner_stride = layout.size as i32; @@ -120,10 +120,10 @@ impl Structurer<'_> { offset: u32, inner: &CanonicalType, repr: crate::cc::ReprId, - element_shape: ValueShape, + element_field: &Field, span: TextRange, ) -> Result<(), Vec> { - let inner_guest = self.resolve_guest(element_shape, span)?; + let inner_guest = self.resolve_field(element_field, span)?; let inner_array_type = self.repr_index(repr, span)?; let layout = abi::canonical::size_align(inner); let inner_stride = layout.size as i32; @@ -251,11 +251,11 @@ impl Structurer<'_> { element: &CanonicalType, length: u32, repr: crate::cc::ReprId, - element_shape: ValueShape, + element_field: &Field, path: &[super::Projection], span: TextRange, ) -> Result<(), Vec> { - let element_guest = self.resolve_guest(element_shape, span)?; + let element_guest = self.resolve_field(element_field, span)?; let array_type = self.repr_index(repr, span)?; let layout = abi::canonical::size_align(element); let array_local = self.node_locals(context.depth).array_local; @@ -319,10 +319,10 @@ impl Structurer<'_> { element: &CanonicalType, length: u32, repr: crate::cc::ReprId, - element_shape: ValueShape, + element_field: &Field, span: TextRange, ) -> Result<(), Vec> { - let element_guest = self.resolve_guest(element_shape, span)?; + let element_guest = self.resolve_field(element_field, span)?; let array_type = self.repr_index(repr, span)?; let layout = abi::canonical::size_align(element); let array_local = self.node_locals(context.depth).array_local; diff --git a/crates/psrs-backend/src/wasm/lower/structure/lists/record.rs b/crates/psrs-backend/src/wasm/lower/structure/lists/record/mod.rs similarity index 84% rename from crates/psrs-backend/src/wasm/lower/structure/lists/record.rs rename to crates/psrs-backend/src/wasm/lower/structure/lists/record/mod.rs index f500ddfa..56d07fd6 100644 --- a/crates/psrs-backend/src/wasm/lower/structure/lists/record.rs +++ b/crates/psrs-backend/src/wasm/lower/structure/lists/record/mod.rs @@ -4,6 +4,8 @@ //! boolean fields into the canonical 1/2/4-byte word (or several `i32` words //! when it has more than 32 flags). +mod storage; + use super::super::wasm_error; use super::{ListLoop, Projection, Structurer}; use crate::BackendError; @@ -120,7 +122,7 @@ impl Structurer<'_> { /// Packs a flags node into its canonical words. #[allow(clippy::too_many_arguments)] - pub(super) fn emit_flags_store( + pub(super) fn emit_flags_store_layout( &self, body: &mut Body, context: &ListLoop, @@ -128,6 +130,7 @@ impl Structurer<'_> { names: &[String], repr: crate::cc::ReprId, labels: &[String], + storage: Option, path: &[Projection], span: TextRange, ) -> Result<(), Vec> { @@ -147,11 +150,22 @@ impl Structurer<'_> { continue; } let mut nested = path.to_vec(); - nested.push(Projection::Field { - ty: repr_index, - field: label_index as u32, + nested.push(if let Some(storage) = storage { + Projection::RecordField { + header: repr_index, + values: self.repr_index(storage.values(), span)?, + field: label_index as u32, + } + } else { + Projection::Field { + ty: repr_index, + field: label_index as u32, + } }); self.emit_project(body, context, &nested, span)?; + if storage.is_some() { + self.emit_unbox(body, &crate::abi::canonical::CanonicalType::Bool, span)?; + } let shift = bit % 32; if shift != 0 { body.push(Op::Leaf(Instruction::I32Const(shift as i32))); @@ -180,6 +194,21 @@ impl Structurer<'_> { span: TextRange, ) -> Result<(), Vec> { let repr_index = self.repr_index(repr, span)?; + self.emit_flags_values(body, context, offset, names, labels, false, span)?; + body.push(Op::Leaf(Instruction::StructNew(repr_index))); + Ok(()) + } + #[allow(clippy::too_many_arguments)] + pub(super) fn emit_flags_values( + &self, + body: &mut Body, + context: &ListLoop, + offset: u32, + names: &[String], + labels: &[String], + erased: bool, + span: TextRange, + ) -> Result<(), Vec> { let words = names.len().div_ceil(32); for label in labels { let Some(bit) = names @@ -197,10 +226,27 @@ impl Structurer<'_> { } body.push(Op::Leaf(Instruction::I32Const(1))); body.push(Op::Leaf(Instruction::I32And)); + if erased { + self.emit_box(body, &crate::abi::canonical::CanonicalType::Bool, span)?; + } } - body.push(Op::Leaf(Instruction::StructNew(repr_index))); Ok(()) } + + #[allow(clippy::too_many_arguments)] + pub(super) fn emit_flags_store( + &self, + body: &mut Body, + context: &ListLoop, + offset: u32, + names: &[String], + repr: crate::cc::ReprId, + labels: &[String], + path: &[Projection], + span: TextRange, + ) -> Result<(), Vec> { + self.emit_flags_store_layout(body, context, offset, names, repr, labels, None, path, span) + } } fn flags_width(count: usize) -> u32 { diff --git a/crates/psrs-backend/src/wasm/lower/structure/lists/record/storage/check.rs b/crates/psrs-backend/src/wasm/lower/structure/lists/record/storage/check.rs new file mode 100644 index 00000000..298c07bc --- /dev/null +++ b/crates/psrs-backend/src/wasm/lower/structure/lists/record/storage/check.rs @@ -0,0 +1,86 @@ +use super::*; + +impl Structurer<'_> { + pub(super) fn check_record_storage( + &self, + body: &mut Body, + context: &ListLoop, + path: &[Projection], + types: &RecordTypes, + labels: &[String], + span: TextRange, + ) -> Result<(), Vec> { + for column in [0, 1] { + self.record_column(body, context, path, types, column, span)?; + body.push(Op::Leaf(Instruction::ArrayLen)); + body.push(Op::Leaf(Instruction::I32Const(labels.len() as i32))); + body.push(Op::Leaf(Instruction::I32Eq)); + require(body, span); + } + for (index, label) in labels.iter().enumerate() { + self.record_key(body, context, path, types, index, span)?; + body.push(Op::Leaf(Instruction::ArrayLen)); + body.push(Op::Leaf(Instruction::I32Const(label.len() as i32))); + body.push(Op::Leaf(Instruction::I32Eq)); + require(body, span); + for (byte_index, byte) in label.bytes().enumerate() { + self.record_key(body, context, path, types, index, span)?; + body.push(Op::Leaf(Instruction::I32Const(byte_index as i32))); + body.push(Op::Leaf(Instruction::ArrayGetU(types.string))); + body.push(Op::Leaf(Instruction::I32Const(i32::from(byte)))); + body.push(Op::Leaf(Instruction::I32Eq)); + require(body, span); + } + } + Ok(()) + } + + #[allow(clippy::too_many_arguments)] + fn record_key( + &self, + body: &mut Body, + context: &ListLoop, + path: &[Projection], + types: &RecordTypes, + index: usize, + span: TextRange, + ) -> Result<(), Vec> { + self.record_column(body, context, path, types, 0, span)?; + body.push(Op::Leaf(Instruction::I32Const(index as i32))); + body.push(Op::Leaf(Instruction::ArrayGet(types.labels))); + Ok(()) + } + + #[allow(clippy::too_many_arguments)] + fn record_column( + &self, + body: &mut Body, + context: &ListLoop, + path: &[Projection], + types: &RecordTypes, + column: u32, + span: TextRange, + ) -> Result<(), Vec> { + self.emit_project(body, context, path, span)?; + body.push(Op::Leaf(Instruction::RefCastNonNull( + crate::wasm::convert::heap_type(crate::types::HeapType::Index( + crate::types::DefinedTypeId(types.header), + )), + ))); + body.push(Op::Leaf(Instruction::StructGet { + struct_type_index: types.header, + field_index: column, + })); + Ok(()) + } +} + +fn require(body: &mut Body, span: TextRange) { + body.push(Op::Leaf(Instruction::I32Eqz)); + body.push(Op::If { + then_body: vec![Op::Leaf(Instruction::Unreachable)], + else_body: Vec::new(), + result: None, + span, + }); +} diff --git a/crates/psrs-backend/src/wasm/lower/structure/lists/record/storage/mod.rs b/crates/psrs-backend/src/wasm/lower/structure/lists/record/storage/mod.rs new file mode 100644 index 00000000..c7396d89 --- /dev/null +++ b/crates/psrs-backend/src/wasm/lower/structure/lists/record/storage/mod.rs @@ -0,0 +1,229 @@ +//! Canonical list records use the same labelled store as language erasure. +use super::*; +use crate::abi::canonical::CanonicalField; +use crate::cc::{Field, GuestLayout, RecordStorage}; +use crate::wasm::{Body, Op}; +use wasm_encoder::Instruction; + +mod check; + +struct RecordTypes { + header: u32, + labels: u32, + values: u32, + string: u32, +} + +impl Structurer<'_> { + fn record_storage_types( + &self, + storage: RecordStorage, + span: TextRange, + ) -> Result> { + let layout = self + .layout + .ok_or_else(|| wasm_error(span, "record storage copy has no target layout"))?; + if layout.representation_table().record_storage() != Some(storage) { + return Err(wasm_error( + span, + "record storage view disagrees with its representation owner", + )); + } + Ok(RecordTypes { + header: self.repr_index(storage.representation(), span)?, + labels: self.repr_index(storage.labels(), span)?, + values: self.repr_index(storage.values(), span)?, + string: layout + .string_index() + .ok_or_else(|| wasm_error(span, "record labels have no UTF-8 storage"))? + .0, + }) + } + + #[allow(clippy::too_many_arguments)] + pub(in crate::wasm::lower::structure::lists) fn emit_record_storage_store( + &self, + body: &mut Body, + context: &ListLoop, + offset: u32, + canonical: &[CanonicalField], + storage: RecordStorage, + labels: &[String], + fields: &[Field], + path: &[Projection], + span: TextRange, + ) -> Result<(), Vec> { + check_view(labels, fields, span)?; + let types = self.record_storage_types(storage, span)?; + self.check_record_storage(body, context, path, &types, labels, span)?; + let layouts = abi::layout::record_fields( + canonical + .iter() + .map(|field| abi::layout::parameter_layout(&field.ty)), + ) + .ok_or_else(|| wasm_error(span, "canonical record storage has no field layout"))?; + for (field, (field_offset, _)) in canonical.iter().zip(layouts) { + let label = abi::source_field_name(&field.name); + let index = labels + .iter() + .position(|existing| *existing == label) + .ok_or_else(|| wasm_error(span, "canonical record storage is missing a field"))?; + let guest = self.record_stored_field(&fields[index], span)?; + let mut nested = path.to_vec(); + nested.push(Projection::RecordField { + header: types.header, + values: types.values, + field: index as u32, + }); + self.emit_store_node( + body, + context, + offset + field_offset, + &field.ty, + &guest, + &nested, + span, + )?; + } + Ok(()) + } + + #[allow(clippy::too_many_arguments)] + pub(in crate::wasm::lower::structure::lists) fn emit_record_storage_load( + &self, + body: &mut Body, + context: &ListLoop, + offset: u32, + canonical: &[CanonicalField], + storage: RecordStorage, + labels: &[String], + fields: &[Field], + span: TextRange, + ) -> Result<(), Vec> { + check_view(labels, fields, span)?; + let types = self.record_storage_types(storage, span)?; + let layouts = abi::layout::record_fields( + canonical + .iter() + .map(|field| abi::layout::parameter_layout(&field.ty)), + ) + .ok_or_else(|| wasm_error(span, "canonical record storage has no field layout"))?; + self.record_label_array(body, &types, labels); + for (index, label) in labels.iter().enumerate() { + let field = canonical + .iter() + .position(|field| abi::source_field_name(&field.name) == *label) + .ok_or_else(|| wasm_error(span, "canonical record storage is missing a field"))?; + let guest = self.record_stored_field(&fields[index], span)?; + self.emit_load_node( + body, + context, + offset + layouts[field].0, + &canonical[field].ty, + &guest, + span, + )?; + } + body.push(Op::Leaf(Instruction::ArrayNewFixed { + array_type_index: types.values, + array_size: labels.len() as u32, + })); + body.push(Op::Leaf(Instruction::StructNew(types.header))); + Ok(()) + } + + fn record_stored_field( + &self, + field: &Field, + span: TextRange, + ) -> Result> { + self.resolve_field( + &Field { + value: field.value.clone(), + stored: crate::cc::payload::erased_shape(), + }, + span, + ) + } + + fn record_label_array(&self, body: &mut Body, types: &RecordTypes, labels: &[String]) { + // UTF-8 bytes are fixed compile-time metadata. This construction uses + // the ordinary String storage type and has no source/HIR type tags. + for label in labels { + for byte in label.bytes() { + body.push(Op::Leaf(Instruction::I32Const(i32::from(byte)))); + } + body.push(Op::Leaf(Instruction::ArrayNewFixed { + array_type_index: types.string, + array_size: label.len() as u32, + })); + } + body.push(Op::Leaf(Instruction::ArrayNewFixed { + array_type_index: types.labels, + array_size: labels.len() as u32, + })); + } + + #[allow(clippy::too_many_arguments)] + pub(in crate::wasm::lower::structure::lists) fn emit_record_storage_flags_store( + &self, + body: &mut Body, + context: &ListLoop, + offset: u32, + names: &[String], + storage: RecordStorage, + labels: &[String], + path: &[Projection], + span: TextRange, + ) -> Result<(), Vec> { + let types = self.record_storage_types(storage, span)?; + self.check_record_storage(body, context, path, &types, labels, span)?; + self.emit_flags_store_layout( + body, + context, + offset, + names, + storage.representation(), + labels, + Some(storage), + path, + span, + ) + } + + #[allow(clippy::too_many_arguments)] + pub(in crate::wasm::lower::structure::lists) fn emit_record_storage_flags_load( + &self, + body: &mut Body, + context: &ListLoop, + offset: u32, + names: &[String], + storage: RecordStorage, + labels: &[String], + span: TextRange, + ) -> Result<(), Vec> { + let types = self.record_storage_types(storage, span)?; + self.record_label_array(body, &types, labels); + self.emit_flags_values(body, context, offset, names, labels, true, span)?; + body.push(Op::Leaf(Instruction::ArrayNewFixed { + array_type_index: types.values, + array_size: labels.len() as u32, + })); + body.push(Op::Leaf(Instruction::StructNew(types.header))); + Ok(()) + } +} + +fn check_view( + labels: &[String], + fields: &[Field], + span: TextRange, +) -> Result<(), Vec> { + if labels.len() != fields.len() || labels.windows(2).any(|pair| pair[0] >= pair[1]) { + return Err(wasm_error( + span, + "record storage copy has invalid logical field metadata", + )); + } + Ok(()) +} diff --git a/crates/psrs-cli/tests/command_runner.rs b/crates/psrs-cli/tests/command_runner.rs index 8936a795..08370f3c 100644 --- a/crates/psrs-cli/tests/command_runner.rs +++ b/crates/psrs-cli/tests/command_runner.rs @@ -188,10 +188,10 @@ fn configured_runner_rejects_missing_identity_and_invalid_checked_types() { package.reject("must return Int"); fs::write( package.0.join("lib/Runner.purs"), - "module Runner where\nrun :: forall a. a -> Int\nrun _ = 0\n", + "module Runner where\nimport Types (Job(..))\nrun :: forall a. Job a -> a\nrun (Job action) = action unit\n", ) .unwrap(); - package.reject("must be monomorphic"); + package.reject("must return Int"); fs::write( package.0.join("lib/Runner.purs"), "module Runner where\nnewtype Other = Other Int\nrun :: Other -> Int\nrun (Other value) = value\n", @@ -200,6 +200,49 @@ fn configured_runner_rejects_missing_identity_and_invalid_checked_types() { package.reject("entry type disagrees"); } +#[test] +fn a_polymorphic_runner_uses_the_entries_checked_instance() { + let package = Package::new(); + fs::write( + package.0.join("lib/Runner.purs"), + "module Runner where\nimport Types (Job(..))\nrun :: forall a. Job a -> Int\nrun (Job action) = let _ = action unit in 0\n", + ) + .unwrap(); + for (ty, value) in [ + ("String", "\"ignored\""), + ( + "{ count :: Int, flag :: Boolean }", + "{ count: 42, flag: true }", + ), + ("Int -> Int", "\\x -> x"), + ] { + fs::write( + package.0.join("Main.purs"), + format!( + "module Main where\nimport Types (Job(..))\nmain :: Job ({ty})\nmain = Job (\\_ -> {value})\n" + ), + ) + .unwrap(); + package.execute(0); + } +} + +#[test] +fn runner_instantiation_requires_one_consistent_replacement_for_a_variable() { + let package = Package::new(); + fs::write( + package.0.join("lib/Runner.purs"), + "module Runner where\nimport Types (Job(..))\nrun :: forall a. Job { left :: a, right :: a } -> Int\nrun (Job action) = let _ = action unit in 0\n", + ) + .unwrap(); + fs::write( + package.0.join("Main.purs"), + "module Main where\nimport Types (Job(..))\nmain :: Job { left :: Int, right :: Boolean }\nmain = Job (\\_ -> { left: 42, right: true })\n", + ) + .unwrap(); + package.reject("entry type disagrees"); +} + #[test] fn check_only_does_not_require_an_executable_runner_root() { const CHILD_ROOT: &str = "PSRS_COMMAND_CHECK_ONLY_ROOT"; diff --git a/crates/psrs-core/src/command.rs b/crates/psrs-core/src/command.rs index d656cc9e..c0af9fae 100644 --- a/crates/psrs-core/src/command.rs +++ b/crates/psrs-core/src/command.rs @@ -1,8 +1,10 @@ //! Checked command entry adaptation through ordinary library functions. -use crate::{Declaration, Expr, ExprKind, Module, Type, TypeConstructor, VerifyError, arrow_parts}; +use crate::{ + Declaration, Expr, ExprKind, Module, Type, TypeConstructor, TypeId, VerifyError, arrow_parts, +}; use psrs_hir::SymbolId; -/// Normalize a selected entry with a source runner of type `T -> Int`. +/// Normalize a selected entry with a source runner instantiable at `T -> Int`. /// Both identities must have been resolved by the caller. This neither names /// an effect type nor synthesizes its operations. Failure leaves Core unchanged. pub fn normalize_entry( @@ -33,14 +35,7 @@ pub fn normalize_entry( "configured command runner has no source declaration", ) })?; - if !crate::types::free_variables(module, runner_decl.ty).is_empty() { - return Err(error( - module, - runner, - "configured command runner must be monomorphic", - )); - } - let (input, result) = arrow_parts(&module.types, runner_decl.ty).ok_or_else(|| { + let (_, result) = arrow_parts(&module.types, runner_decl.ty).ok_or_else(|| { error( module, runner, @@ -61,7 +56,20 @@ pub fn normalize_entry( module.entry = Some(entry); return Ok(entry); } - if !module.types_equivalent(source.ty, input) { + // Check the entire callable instance with Core's authoritative scheme + // relation. Repeated variables, row tails and nested quantifiers must obey + // the same rules as an ordinary source global; this is not an Effect rule. + let mut candidate = module.clone(); + let function = intern( + &mut candidate.types, + Type::Constructor(TypeConstructor::Function), + ); + let parameter = intern(&mut candidate.types, Type::Application(function, source.ty)); + let runner_type = intern(&mut candidate.types, Type::Application(parameter, result)); + if candidate + .checked_instantiation(runner_decl.ty, &runner_decl.quantified, runner_type) + .is_none() + { return Err(error( module, entry, @@ -100,7 +108,7 @@ pub fn normalize_entry( ty: result, value: Expr { kind: ExprKind::Application( - Box::new(global(runner, runner_decl.ty)), + Box::new(global(runner, runner_type)), Box::new(global(entry, source.ty)), ), ty: result, @@ -108,7 +116,6 @@ pub fn normalize_entry( }, span, }; - let mut candidate = module.clone(); candidate.declarations.push(wrapper); candidate.entry = Some(symbol); candidate.verify()?; @@ -116,6 +123,15 @@ pub fn normalize_entry( Ok(symbol) } +fn intern(types: &mut Vec, ty: Type) -> TypeId { + if let Some(index) = types.iter().position(|existing| *existing == ty) { + return TypeId(index as u32); + } + let id = TypeId(types.len() as u32); + types.push(ty); + id +} + fn error(module: &Module, symbol: SymbolId, message: &'static str) -> Vec { vec![VerifyError { module: symbol.module, diff --git a/crates/psrs-core/src/dictionary.rs b/crates/psrs-core/src/dictionary.rs index a0af853d..aa4038e2 100644 --- a/crates/psrs-core/src/dictionary.rs +++ b/crates/psrs-core/src/dictionary.rs @@ -225,6 +225,7 @@ mod tests { external_types: Vec::new(), types, newtype_ids: Vec::new(), + ordinary_data_ids: Vec::new(), opaque_ids: Vec::new(), callable_types: Vec::new(), constructors: Vec::new(), diff --git a/crates/psrs-core/src/instantiation/local_rows.rs b/crates/psrs-core/src/instantiation/local_rows.rs deleted file mode 100644 index dbb398fb..00000000 --- a/crates/psrs-core/src/instantiation/local_rows.rs +++ /dev/null @@ -1,357 +0,0 @@ -//! Lower finite closed uses of row-polymorphic local lambdas through checked -//! instantiation. Residual open uses retain their source scheme and remain an -//! explicit unsupported layout at the representation boundary. -use crate::locals::{FreshLocals, clone_with_fresh_locals}; -use crate::{ - Binding, Expr, ExprKind, LocalId, Module, Pattern, PatternKind, Type, TypeId, VerifyError, -}; -use std::collections::{HashMap, HashSet}; - -/// Materializes closed row instantiations of nonrecursive local lambdas. -/// This preparation has no optimization budget: every eligible closed use is -/// lowered. It changes only lambda creation, preserving evaluation and capture -/// of surrounding computations. Core checks the input and transformed output. -pub fn instantiate_local_rows( - mut module: Module, - source: Option<&Module>, -) -> Result> { - module.verify_with_source(source.unwrap_or(&module))?; - for index in 0..module.declarations.len() { - let mut expression = module.declarations[index].value.clone(); - let mut fresh = FreshLocals::for_declaration(&expression); - rewrite( - &mut expression, - &mut module, - &mut fresh, - &mut HashMap::new(), - ); - module.declarations[index].value = expression; - } - module.verify_with_source(source.unwrap_or(&module))?; - Ok(module) -} - -fn rewrite( - expr: &mut Expr, - module: &mut Module, - fresh: &mut FreshLocals, - scope: &mut HashMap, -) { - psrs_span::with_sufficient_stack(|| rewrite_inner(expr, module, fresh, scope)) -} - -fn rewrite_inner( - expr: &mut Expr, - module: &mut Module, - fresh: &mut FreshLocals, - scope: &mut HashMap, -) { - if let ExprKind::Local(id) = expr.kind { - if let Some(binding) = scope.get(&id).cloned() - && let Some(value) = instantiate(module, &binding, expr.ty, fresh) - { - *expr = value; - rewrite(expr, module, fresh, scope); - } - return; - } - match &mut expr.kind { - ExprKind::Lambda { binder, body } => { - let previous = scope.remove(&binder.id); - rewrite(body, module, fresh, scope); - if let Some(binding) = previous { - scope.insert(binder.id, binding); - } - } - ExprKind::Let { bindings, body } => { - let dependencies = bindings - .iter() - .map(|binding| { - ( - binding.binder.id, - bindings - .iter() - .filter(|other| uses(&binding.value, other.binder.id)) - .map(|other| other.binder.id) - .collect::>(), - ) - }) - .collect::>(); - let mut previous = Vec::new(); - for binding in bindings.iter() { - previous.push((binding.binder.id, scope.remove(&binding.binder.id))); - // Restrict this preparation to nonrecursive lambdas. Keeping a - // recursive source binding is an explicit layout obligation. - let recursive = - dependencies - .get(&binding.binder.id) - .is_some_and(|dependencies_from_binding| { - dependencies_from_binding.iter().any(|id| { - reaches(*id, binding.binder.id, &dependencies, &mut HashSet::new()) - }) - }); - if !recursive - && !quantifiers(module, binding).is_empty() - && matches!(binding.value.kind, ExprKind::Lambda { .. }) - { - scope.insert(binding.binder.id, binding.clone()); - } - } - for binding in bindings.iter_mut() { - rewrite(&mut binding.value, module, fresh, scope); - } - rewrite(body, module, fresh, scope); - let removable = bindings - .iter() - .filter(|binding| { - scope.contains_key(&binding.binder.id) - && !uses(body, binding.binder.id) - && !bindings - .iter() - .any(|other| uses(&other.value, binding.binder.id)) - }) - .map(|binding| binding.binder.id) - .collect::>(); - bindings.retain(|binding| !removable.contains(&binding.binder.id)); - for (id, binding) in previous { - scope.remove(&id); - if let Some(binding) = binding { - scope.insert(id, binding); - } - } - } - ExprKind::Case { - scrutinee, - branches, - } => { - rewrite(scrutinee, module, fresh, scope); - for branch in branches { - let mut ids = Vec::new(); - pattern_ids(&branch.pattern, &mut ids); - let previous = ids - .into_iter() - .map(|id| (id, scope.remove(&id))) - .collect::>(); - rewrite(&mut branch.value, module, fresh, scope); - for (id, binding) in previous { - if let Some(binding) = binding { - scope.insert(id, binding); - } - } - } - } - _ => { - for child in children(expr) { - rewrite(child, module, fresh, scope); - } - } - } -} - -fn instantiate( - module: &mut Module, - binding: &Binding, - use_type: TypeId, - fresh: &mut FreshLocals, -) -> Option { - let rows = { - let quantified = quantifiers(module, binding); - let proof = module.checked_instantiation(binding.binder.ty, &quantified, use_type)?; - quantified - .iter() - .map(|variable| { - let row = proof.row(*variable)?; - if row.tail.is_some() { - return None; - } - Some((*variable, row.fields)) - }) - .collect::>>()? - }; - let mut replacements = HashMap::new(); - let empty = module - .types - .iter() - .position(|ty| matches!(ty, Type::RowEmpty))?; - for (variable, fields) in rows { - let mut tail = TypeId(empty as u32); - for (label, ty) in fields.into_iter().rev() { - let node = Type::RowExtend { label, ty, tail }; - tail = if let Some(index) = module.types.iter().position(|ty| *ty == node) { - TypeId(index as u32) - } else { - let id = TypeId(u32::try_from(module.types.len()).ok()?); - module.types.push(node); - id - }; - } - replacements.insert(variable, tail); - } - let value = super::substitution::instantiate_expression(module, &binding.value, &replacements)?; - clone_with_fresh_locals(&value, fresh) -} - -fn quantifiers(module: &Module, binding: &Binding) -> Vec { - let mut variables = binding.quantified.clone(); - let mut ty = binding.binder.ty; - while let Some(Type::ForAll { - variables: bound, - body, - }) = module.types.get(ty.0 as usize) - { - variables.extend(bound); - ty = *body; - } - variables -} - -fn children(expr: &mut Expr) -> Vec<&mut Expr> { - match &mut expr.kind { - ExprKind::Constructor { arguments, .. } - | ExprKind::IntrinsicCall { arguments, .. } - | ExprKind::Array { - elements: arguments, - } => arguments.iter_mut().collect(), - ExprKind::Record { fields } => fields.iter_mut().map(|(_, value)| value).collect(), - ExprKind::RecordUpdate { record, fields } => std::iter::once(record.as_mut()) - .chain(fields.iter_mut().map(|(_, value)| value)) - .collect(), - ExprKind::FieldAccess { record, .. } - | ExprKind::RepresentationCast { value: record, .. } => vec![record], - ExprKind::Application(function, argument) => vec![function, argument], - ExprKind::Lambda { body, .. } => vec![body], - ExprKind::Let { bindings, body } => bindings - .iter_mut() - .map(|binding| &mut binding.value) - .chain(std::iter::once(body.as_mut())) - .collect(), - ExprKind::If { - condition, - then_branch, - else_branch, - } => vec![condition, then_branch, else_branch], - ExprKind::Case { - scrutinee, - branches, - } => std::iter::once(scrutinee.as_mut()) - .chain(branches.iter_mut().map(|branch| &mut branch.value)) - .collect(), - ExprKind::Local(_) - | ExprKind::Global(_) - | ExprKind::Integer(_) - | ExprKind::Number(_) - | ExprKind::Boolean(_) - | ExprKind::String(_) - | ExprKind::Char(_) - | ExprKind::Unit - | ExprKind::Trap => Vec::new(), - } -} - -fn uses(expr: &Expr, id: LocalId) -> bool { - // The traversal is scope aware, including malformed/reused local identities - // accepted as lexical shadowing by Core's verifier. - match &expr.kind { - ExprKind::Local(found) => *found == id, - ExprKind::Lambda { binder, .. } if binder.id == id => false, - ExprKind::Let { bindings, .. } - if bindings.iter().any(|binding| binding.binder.id == id) => - { - false - } - ExprKind::Case { - scrutinee, - branches, - } => { - uses(scrutinee, id) - || branches.iter().any(|branch| { - let mut ids = Vec::new(); - pattern_ids(&branch.pattern, &mut ids); - !ids.contains(&id) && uses(&branch.value, id) - }) - } - _ => children_ref(expr).into_iter().any(|child| uses(child, id)), - } -} - -fn reaches( - current: LocalId, - target: LocalId, - dependencies: &HashMap>, - seen: &mut HashSet, -) -> bool { - current == target - || (seen.insert(current) - && dependencies.get(¤t).is_some_and(|next| { - next.iter() - .any(|id| reaches(*id, target, dependencies, seen)) - })) -} - -fn children_ref(expr: &Expr) -> Vec<&Expr> { - match &expr.kind { - ExprKind::Constructor { arguments, .. } - | ExprKind::IntrinsicCall { arguments, .. } - | ExprKind::Array { - elements: arguments, - } => arguments.iter().collect(), - ExprKind::Record { fields } => fields.iter().map(|(_, value)| value).collect(), - ExprKind::RecordUpdate { record, fields } => std::iter::once(record.as_ref()) - .chain(fields.iter().map(|(_, value)| value)) - .collect(), - ExprKind::FieldAccess { record, .. } - | ExprKind::RepresentationCast { value: record, .. } => vec![record], - ExprKind::Application(function, argument) => vec![function, argument], - ExprKind::Lambda { body, .. } => vec![body], - ExprKind::Let { bindings, body } => bindings - .iter() - .map(|binding| &binding.value) - .chain(std::iter::once(body.as_ref())) - .collect(), - ExprKind::If { - condition, - then_branch, - else_branch, - } => vec![condition, then_branch, else_branch], - ExprKind::Case { - scrutinee, - branches, - } => std::iter::once(scrutinee.as_ref()) - .chain(branches.iter().map(|branch| &branch.value)) - .collect(), - ExprKind::Local(_) - | ExprKind::Global(_) - | ExprKind::Integer(_) - | ExprKind::Number(_) - | ExprKind::Boolean(_) - | ExprKind::String(_) - | ExprKind::Char(_) - | ExprKind::Unit - | ExprKind::Trap => Vec::new(), - } -} - -fn pattern_ids(pattern: &Pattern, ids: &mut Vec) { - match &pattern.kind { - PatternKind::Var { id, .. } => ids.push(*id), - PatternKind::Named { id, pattern } => { - ids.push(*id); - pattern_ids(pattern, ids); - } - PatternKind::Array { elements } - | PatternKind::Constructor { - arguments: elements, - .. - } => { - for pattern in elements { - pattern_ids(pattern, ids); - } - } - PatternKind::Record { fields } => { - for (_, pattern) in fields { - pattern_ids(pattern, ids); - } - } - PatternKind::Wildcard | PatternKind::Literal { .. } => {} - } -} diff --git a/crates/psrs-core/src/instantiation/mod.rs b/crates/psrs-core/src/instantiation/mod.rs index e94f9f5c..6663b598 100644 --- a/crates/psrs-core/src/instantiation/mod.rs +++ b/crates/psrs-core/src/instantiation/mod.rs @@ -1,7 +1,5 @@ //! Read-only instantiation evidence and checked materialization. -mod local_rows; pub(crate) mod substitution; -pub use local_rows::instantiate_local_rows; use crate::{Module, Type, TypeConstructor, TypeId}; use psrs_hir::TypeVariableId; diff --git a/crates/psrs-core/src/instantiation/substitution.rs b/crates/psrs-core/src/instantiation/substitution.rs index 685e1fe7..b8d347ea 100644 --- a/crates/psrs-core/src/instantiation/substitution.rs +++ b/crates/psrs-core/src/instantiation/substitution.rs @@ -24,7 +24,6 @@ pub(crate) fn instantiate_declaration( shadowed: HashSet::new(), renamed: HashMap::new(), active: HashSet::new(), - discharged: HashSet::new(), }; let ty = substitution.type_id(declaration.ty)?; let value = substitution.expression(&declaration.value)?; @@ -39,29 +38,6 @@ pub(crate) fn instantiate_declaration( }) } -pub(super) fn instantiate_expression( - module: &mut Module, - expression: &Expr, - replacements: &HashMap, -) -> Option { - let mut replacement_free_variables = HashSet::new(); - for id in replacements.values() { - replacement_free_variables.extend(crate::types::free_variables(module, *id)); - } - let used_variables = all_variables(module); - TypeSubstitution { - module, - replacements, - replacement_free_variables, - used_variables, - shadowed: HashSet::new(), - renamed: HashMap::new(), - active: HashSet::new(), - discharged: replacements.keys().copied().collect(), - } - .expression(expression) -} - struct TypeSubstitution<'a> { module: &'a mut Module, replacements: &'a HashMap, @@ -70,7 +46,6 @@ struct TypeSubstitution<'a> { shadowed: HashSet, renamed: HashMap, active: HashSet, - discharged: HashSet, } impl TypeSubstitution<'_> { @@ -96,13 +71,7 @@ impl TypeSubstitution<'_> { let argument = self.type_id(argument)?; self.intern(Type::Application(function, argument))? } - Type::ForAll { variables, body } - if variables - .iter() - .all(|variable| self.discharged.contains(variable)) => - { - self.type_id(body)? - } + Type::ForAll { variables, body } if variables.is_empty() => self.type_id(body)?, Type::ForAll { variables, body } => { let mut renamed_variables = variables.clone(); let mut previous_renamings = Vec::new(); diff --git a/crates/psrs-core/src/lib.rs b/crates/psrs-core/src/lib.rs index d6507c71..9d5d7fde 100644 --- a/crates/psrs-core/src/lib.rs +++ b/crates/psrs-core/src/lib.rs @@ -14,7 +14,7 @@ pub mod opt; mod pattern; pub mod primitive; mod records; -pub use instantiation::{Instantiation, RowInstantiation, instantiate_local_rows}; +pub use instantiation::{Instantiation, RowInstantiation}; mod types; mod verify; @@ -75,6 +75,9 @@ pub struct Module { /// Nominal newtypes that are represented by their single field below Core. /// This is representation metadata, not a change to the source type. pub newtype_ids: Vec, + /// Ordinary algebraic data declarations, including constructorless data. + /// This positive declaration-kind evidence survives linking and lowering. + pub ordinary_data_ids: Vec, /// Foreign data declarations. The type node stays /// `Constructor(User(HirTypeId))`; this set records that the type is opaque /// and has no constructors. It is not a calling-convention layout. diff --git a/crates/psrs-core/src/link/mod.rs b/crates/psrs-core/src/link/mod.rs index d911bacf..17c720ce 100644 --- a/crates/psrs-core/src/link/mod.rs +++ b/crates/psrs-core/src/link/mod.rs @@ -23,6 +23,7 @@ pub fn link(modules: Vec) -> Module { .map_or_else(|| TextRange::new(0, 0), |module| module.span); let mut types = Vec::new(); let mut newtype_ids = Vec::new(); + let mut ordinary_data_ids = Vec::new(); let mut opaque_ids = Vec::new(); let mut callable_types: Vec<(psrs_hir::TypeId, u32)> = Vec::new(); let mut constructors = Vec::new(); @@ -44,6 +45,7 @@ pub fn link(modules: Vec) -> Module { types.push(shift_type(ty, offset, module_variable_offset)); } newtype_ids.extend(module.newtype_ids); + ordinary_data_ids.extend(module.ordinary_data_ids); opaque_ids.extend(module.opaque_ids); for callable in module.callable_types { if !callable_types.contains(&callable) { @@ -101,6 +103,7 @@ pub fn link(modules: Vec) -> Module { external_types, types, newtype_ids, + ordinary_data_ids, opaque_ids, callable_types, constructors, diff --git a/crates/psrs-core/src/lower/module.rs b/crates/psrs-core/src/lower/module.rs index c153c440..ae9ca9d9 100644 --- a/crates/psrs-core/src/lower/module.rs +++ b/crates/psrs-core/src/lower/module.rs @@ -244,6 +244,7 @@ pub(super) fn lower_module_inner(module: psrs_thir::Module) -> Result, declaration_type: u32, value: Expr) -> Module { external_types: Vec::new(), types, newtype_ids: Vec::new(), + ordinary_data_ids: Vec::new(), opaque_ids: Vec::new(), callable_types: Vec::new(), constructors: Vec::new(), diff --git a/crates/psrs-core/src/opt/tests/patterns.rs b/crates/psrs-core/src/opt/tests/patterns.rs index 572e0207..f24bd231 100644 --- a/crates/psrs-core/src/opt/tests/patterns.rs +++ b/crates/psrs-core/src/opt/tests/patterns.rs @@ -114,6 +114,7 @@ fn nested_record_case(second: i32) -> Module { external_types: Vec::new(), types: std::mem::take(&mut types), newtype_ids: Vec::new(), + ordinary_data_ids: Vec::new(), opaque_ids: Vec::new(), callable_types: Vec::new(), constructors: Vec::new(), diff --git a/crates/psrs-core/src/tests/dictionaries.rs b/crates/psrs-core/src/tests/dictionaries.rs index 782d3e0b..08036b2b 100644 --- a/crates/psrs-core/src/tests/dictionaries.rs +++ b/crates/psrs-core/src/tests/dictionaries.rs @@ -73,6 +73,7 @@ fn lowering_erases_instance_and_superclass_evidence_to_calls_and_projections() { external_types: Vec::new(), types, newtype_ids: Vec::new(), + ordinary_data_ids: Vec::new(), opaque_ids: Vec::new(), callable_types: Vec::new(), constructors: Vec::new(), @@ -220,6 +221,7 @@ fn lowering_erases_global_dictionary_evidence_to_a_core_global() { thir::Type::Application(thir::TypeId(3), thir::TypeId(2)), ], newtype_ids: Vec::new(), + ordinary_data_ids: Vec::new(), opaque_ids: Vec::new(), callable_types: Vec::new(), constructors: Vec::new(), diff --git a/crates/psrs-core/src/tests/external_types.rs b/crates/psrs-core/src/tests/external_types.rs index a797a7b0..2b2c23df 100644 --- a/crates/psrs-core/src/tests/external_types.rs +++ b/crates/psrs-core/src/tests/external_types.rs @@ -24,6 +24,7 @@ fn fixture() -> Module { }], types: vec![Type::Constructor(TypeConstructor::Int)], newtype_ids: Vec::new(), + ordinary_data_ids: Vec::new(), opaque_ids: Vec::new(), callable_types: Vec::new(), constructors: Vec::new(), diff --git a/crates/psrs-core/src/tests/instantiation/mod.rs b/crates/psrs-core/src/tests/instantiation/mod.rs index 88db4ca1..2e184186 100644 --- a/crates/psrs-core/src/tests/instantiation/mod.rs +++ b/crates/psrs-core/src/tests/instantiation/mod.rs @@ -18,6 +18,7 @@ fn bare(types: Vec) -> Module { external_types: Vec::new(), types, newtype_ids: Vec::new(), + ordinary_data_ids: Vec::new(), opaque_ids: Vec::new(), callable_types: Vec::new(), constructors: Vec::new(), diff --git a/crates/psrs-core/src/tests/link.rs b/crates/psrs-core/src/tests/link.rs index f6b3c9ed..8a1164a8 100644 --- a/crates/psrs-core/src/tests/link.rs +++ b/crates/psrs-core/src/tests/link.rs @@ -17,6 +17,7 @@ fn module_with( external_types: Vec::new(), types, newtype_ids: Vec::new(), + ordinary_data_ids: Vec::new(), opaque_ids: Vec::new(), callable_types: Vec::new(), constructors, diff --git a/crates/psrs-core/src/tests/mod.rs b/crates/psrs-core/src/tests/mod.rs index c0d7aaee..0e37216e 100644 --- a/crates/psrs-core/src/tests/mod.rs +++ b/crates/psrs-core/src/tests/mod.rs @@ -34,6 +34,7 @@ fn verifier_rejects_out_of_range_types() { external_types: Vec::new(), types: vec![Type::Constructor(crate::TypeConstructor::Int)], newtype_ids: Vec::new(), + ordinary_data_ids: Vec::new(), opaque_ids: Vec::new(), callable_types: Vec::new(), constructors: Vec::new(), @@ -70,6 +71,7 @@ fn verifier_attributes_declaration_errors_to_their_source_module() { external_types: Vec::new(), types: vec![Type::Constructor(crate::TypeConstructor::Int)], newtype_ids: Vec::new(), + ordinary_data_ids: Vec::new(), opaque_ids: Vec::new(), callable_types: Vec::new(), constructors: Vec::new(), @@ -103,6 +105,7 @@ fn single_declaration(types: Vec, declaration_type: TypeId, value: Expr) - external_types: Vec::new(), types, newtype_ids: Vec::new(), + ordinary_data_ids: Vec::new(), opaque_ids: Vec::new(), callable_types: Vec::new(), constructors: Vec::new(), diff --git a/crates/psrs-core/src/tests/patterns.rs b/crates/psrs-core/src/tests/patterns.rs index 72c6d421..dab121f3 100644 --- a/crates/psrs-core/src/tests/patterns.rs +++ b/crates/psrs-core/src/tests/patterns.rs @@ -70,6 +70,7 @@ fn case_module( external_types: Vec::new(), types, newtype_ids: Vec::new(), + ordinary_data_ids: Vec::new(), opaque_ids: Vec::new(), callable_types: Vec::new(), constructors: Vec::new(), diff --git a/crates/psrs-core/src/tests/rank_n/mod.rs b/crates/psrs-core/src/tests/rank_n/mod.rs index 81f116ff..39b15e42 100644 --- a/crates/psrs-core/src/tests/rank_n/mod.rs +++ b/crates/psrs-core/src/tests/rank_n/mod.rs @@ -43,6 +43,7 @@ fn module(types: Vec, declarations: Vec) -> Module { external_types: Vec::new(), types, newtype_ids: Vec::new(), + ordinary_data_ids: Vec::new(), opaque_ids: Vec::new(), callable_types: Vec::new(), constructors: Vec::new(), diff --git a/crates/psrs-core/src/tests/record_scope.rs b/crates/psrs-core/src/tests/record_scope.rs index 48b390cd..d158c399 100644 --- a/crates/psrs-core/src/tests/record_scope.rs +++ b/crates/psrs-core/src/tests/record_scope.rs @@ -109,6 +109,7 @@ fn fixture(unrelated: bool, sibling: bool) -> Module { externals: vec![], external_types: vec![], newtype_ids: vec![], + ordinary_data_ids: Vec::new(), opaque_ids: vec![], callable_types: vec![], constructors: vec![], diff --git a/crates/psrs-core/src/tests/rows.rs b/crates/psrs-core/src/tests/rows.rs index 2859f17d..3dfb9824 100644 --- a/crates/psrs-core/src/tests/rows.rs +++ b/crates/psrs-core/src/tests/rows.rs @@ -84,6 +84,7 @@ fn packaged(types: Vec, declarations: Vec) -> Module { external_types: Vec::new(), types, newtype_ids: Vec::new(), + ordinary_data_ids: Vec::new(), opaque_ids: Vec::new(), callable_types: Vec::new(), constructors: Vec::new(), diff --git a/crates/psrs-core/src/verify/mod.rs b/crates/psrs-core/src/verify/mod.rs index b9788703..3a067191 100644 --- a/crates/psrs-core/src/verify/mod.rs +++ b/crates/psrs-core/src/verify/mod.rs @@ -25,6 +25,7 @@ pub(super) struct SchemeType { type Locals = HashMap; pub(crate) fn module(module: &Module, source: Option<&Module>) -> Result<(), Vec> { + let kind_metadata = validate_type_kind_metadata(module); let globals = module .declarations .iter() @@ -46,7 +47,7 @@ pub(crate) fn module(module: &Module, source: Option<&Module>) -> Result<(), Vec (external.symbol, signature) })) .collect::>(); - let mut errors = Vec::new(); + let mut errors = kind_metadata; let mut external_type_symbols = std::collections::HashSet::new(); for external_type in &module.external_types { let span = module @@ -160,7 +161,75 @@ pub(crate) fn module(module: &Module, source: Option<&Module>) -> Result<(), Vec } } +fn validate_type_kind_metadata(module: &Module) -> Vec { + let newtypes = module + .newtype_ids + .iter() + .copied() + .collect::>(); + let ordinary = module + .ordinary_data_ids + .iter() + .copied() + .collect::>(); + let opaque = module + .opaque_ids + .iter() + .copied() + .collect::>(); + if newtypes.len() == module.newtype_ids.len() + && ordinary.len() == module.ordinary_data_ids.len() + && opaque.len() == module.opaque_ids.len() + && newtypes + .iter() + .all(|id| !ordinary.contains(id) && !opaque.contains(id)) + && ordinary.iter().all(|id| !opaque.contains(id)) + { + Vec::new() + } else { + vec![error( + module.id, + module.span, + "type declaration kind metadata is duplicated or contradictory", + )] + } +} + fn external_scheme(module: &Module, ty: TypeId) -> Option { let (quantified, ty) = crate::scheme_parts(&module.types, ty)?; Some(SchemeType { ty, quantified }) } + +#[cfg(test)] +mod type_kind_tests { + use super::*; + use psrs_hir::{ModuleId, TypeId as HirTypeId}; + use psrs_span::TextRange; + + #[test] + fn rejects_ordinary_data_that_is_also_declared_as_a_newtype() { + let id = HirTypeId::new(ModuleId(1), 2); + let module = Module { + id: ModuleId(1), + name: "Carrier".into(), + externals: Vec::new(), + external_types: Vec::new(), + types: Vec::new(), + newtype_ids: vec![id], + ordinary_data_ids: vec![id], + opaque_ids: Vec::new(), + callable_types: Vec::new(), + constructors: Vec::new(), + declarations: Vec::new(), + type_names: vec![(id, "Carrier.Cell".into())], + entry: None, + span: TextRange::new(0, 1), + }; + let errors = validate_type_kind_metadata(&module); + assert!( + errors + .iter() + .any(|error| error.message.contains("duplicated or contradictory")) + ); + } +} diff --git a/crates/psrs-coverage/Cargo.toml b/crates/psrs-coverage/Cargo.toml new file mode 100644 index 00000000..36316285 --- /dev/null +++ b/crates/psrs-coverage/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "psrs-coverage" +version = "0.1.0" +edition = "2024" +license = "MIT" + +[dependencies] +psrs-hir = { path = "../psrs-hir" } +psrs-span = { path = "../psrs-span" } diff --git a/crates/psrs-coverage/src/analyze.rs b/crates/psrs-coverage/src/analyze.rs new file mode 100644 index 00000000..7fa04083 --- /dev/null +++ b/crates/psrs-coverage/src/analyze.rs @@ -0,0 +1,96 @@ +//! Exhaustiveness, witness, and redundancy over one case's alternatives. +//! +//! The analysis owns semantic coverage facts. The type checker turns a missing +//! witness into the official `Partial` authorization flow; the decision +//! compiler turns the same report into first-match lowering and its rejection +//! messages. Generated trap fallbacks participate in coverage exactly as an +//! authorized `Partial` discharge; generated branch shapes are excluded from +//! diagnostics the official linter never sees. + +use psrs_hir::CaseBranchCoverage; + +use crate::engine::useful; +use crate::pattern::Pattern; +use crate::render::render; +use crate::types::Types; + +/// One alternative entering analysis: its coverage class and its +/// binder-erased pattern. +#[derive(Clone, Debug)] +pub struct Branch { + pub coverage: CaseBranchCoverage, + pub pattern: Pattern, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Analysis { + pub exhaustive: bool, + /// The missing binder atoms of the first uncovered row, in column order, + /// or `None` when the alternatives cover all inputs. + pub witness_atoms: Option>, + /// Alternative indexes matched by an earlier alternative. + pub redundant_branches: Vec, +} + +impl Analysis { + /// The backend decision compiler's existing message: the first column of + /// the witness. + pub fn first_witness_atom(&self) -> Option<&str> { + self.witness_atoms + .as_deref() + .and_then(|row| row.first()) + .map(String::as_str) + } +} + +pub fn analyze>(types: &T, scrutinee_type: Ty, branches: &[Branch]) -> Analysis +where + Ty: Clone + Copy + std::fmt::Debug + Eq + std::hash::Hash, +{ + if !branches.is_empty() + && branches + .iter() + .all(|branch| branch.coverage == CaseBranchCoverage::Generated) + { + return Analysis { + exhaustive: true, + witness_atoms: None, + redundant_branches: Vec::new(), + }; + } + let matrix = branches + .iter() + .filter(|branch| { + matches!( + branch.coverage, + CaseBranchCoverage::Source | CaseBranchCoverage::PartialFallback + ) + }) + .map(|branch| vec![branch.pattern.clone()]) + .collect::>>>(); + let query = vec![Pattern::Any { ty: scrutinee_type }]; + let witness_atoms = useful(types, &matrix, &query) + .map(|row| row.iter().map(|pattern| render(types, pattern)).collect()); + let mut prior = Vec::new(); + let mut redundant_branches = Vec::new(); + for (index, branch) in branches.iter().enumerate() { + if matches!( + branch.coverage, + CaseBranchCoverage::Generated | CaseBranchCoverage::PartialFallback + ) { + continue; + } + let query = vec![branch.pattern.clone()]; + if useful(types, &prior, &query).is_none() { + redundant_branches.push(index); + } + if branch.coverage == CaseBranchCoverage::Source { + prior.push(query); + } + } + Analysis { + exhaustive: witness_atoms.is_none(), + witness_atoms, + redundant_branches, + } +} diff --git a/crates/psrs-coverage/src/engine/literal.rs b/crates/psrs-coverage/src/engine/literal.rs new file mode 100644 index 00000000..8e8ca520 --- /dev/null +++ b/crates/psrs-coverage/src/engine/literal.rs @@ -0,0 +1,25 @@ +use crate::pattern::Literal; + +/// Equates the spellings the official linter's `missingCases` treats +/// identically: finite `Number` literals reduce to their shortest standard +/// rendering, zero keeps a single spelling, and every other scalar is its own +/// canonical form. +pub(crate) fn canonical_literal(literal: &Literal) -> Literal { + match literal { + Literal::Number(text) => text + .parse::() + .ok() + .filter(|number| number.is_finite()) + .map_or_else( + || literal.clone(), + |number| { + if number == 0.0 { + Literal::Number("0".into()) + } else { + Literal::Number(number.to_string()) + } + }, + ), + _ => literal.clone(), + } +} diff --git a/crates/psrs-coverage/src/engine/mod.rs b/crates/psrs-coverage/src/engine/mod.rs new file mode 100644 index 00000000..0c0a294e --- /dev/null +++ b/crates/psrs-coverage/src/engine/mod.rs @@ -0,0 +1,484 @@ +//! Shared pattern-matrix usefulness and witness reconstruction. +//! +//! This is the one usefulness engine. Its inputs are the neutral [`Pattern`] +//! algebra and the [`Types`] facts; no stage's internal representation reaches +//! here. The algorithm is unchanged from the decision compiler's original +//! analysis and keeps its documented behaviors: an incomplete constructor +//! signature uses the default matrix path, uninhabited user types stay +//! unknown, and the active-state set bounds recursive product expansion. + +mod literal; +pub(crate) use literal::canonical_literal; + +use std::collections::HashSet; + +use psrs_hir::SymbolId; + +use crate::pattern::{Literal, Pattern}; +use crate::types::Types; + +/// The shared engine parameterized by a stage's type identity `Ty`. +pub(crate) type EngineResult = Option>>; + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +enum Head { + Constructor(SymbolId), + Record, + Literal(Literal), + ArrayLength(usize), +} + +#[derive(Clone, Debug)] +struct Shape { + ty: Ty, + head: Head, + fields: Vec<(Option, Ty)>, +} + +type Matrix = Vec>>; +type AnalysisState = (Matrix, Vec>); + +pub(crate) fn useful>( + types: &T, + matrix: &Matrix, + query: &[Pattern], +) -> EngineResult +where + Ty: Clone + Copy + std::fmt::Debug + Eq + std::hash::Hash, +{ + useful_with_active(types, matrix, query, &mut HashSet::new()) +} + +fn useful_with_active>( + types: &T, + matrix: &Matrix, + query: &[Pattern], + active: &mut HashSet>, +) -> EngineResult +where + Ty: Clone + Copy + std::fmt::Debug + Eq + std::hash::Hash, +{ + // A query a row of wildcards already proves is never worth expanding: the + // guard bounds specialization growth before the query matrix can grow it. + if matrix.iter().any(|row| { + row.len() == query.len() + && row + .iter() + .all(|pattern| matches!(pattern, Pattern::Any { .. })) + }) { + return None; + } + let state = (matrix.clone(), query.to_vec()); + if !active.insert(state.clone()) { + return None; + } + let result = useful_inner(types, matrix, query, active); + active.remove(&state); + result +} + +fn useful_inner>( + types: &T, + matrix: &Matrix, + query: &[Pattern], + active: &mut HashSet>, +) -> EngineResult +where + Ty: Clone + Copy + std::fmt::Debug + Eq + std::hash::Hash, +{ + let Some(head) = query.first() else { + return matrix.is_empty().then(Vec::new); + }; + let tail = &query[1..]; + if let Pattern::Any { ty } = head { + if let Some(element_type) = types.array_element(*ty) { + return useful_array_any(types, matrix, query, tail, *ty, element_type, active); + } + if let Some(shapes) = signature(types, *ty) { + // An incomplete signature uses the default matrix. Expanding every + // constructor here grows unconstrained recursive product columns + // forever, even when the matrix is empty. Only a complete set of + // observed heads needs field-by-field specialization. + let missing = shapes + .iter() + .filter(|shape| { + !matrix + .iter() + .filter_map(|row| row.first()) + .any(|pattern| head_of(pattern).as_ref() == Some(&shape.head)) + }) + .collect::>(); + if !missing.is_empty() { + let witness = useful_with_active(types, &default_matrix(matrix), tail, active)?; + for shape in missing { + if let Some(head) = inhabited_shape(types, shape, &mut Default::default()) { + return Some(std::iter::once(head).chain(witness).collect()); + } + } + // Missing uninhabited constructors leave observed fields to + // check; their partial patterns can still expose a witness. + } + for shape in shapes { + let specialized = specialize(matrix, &shape); + let mut specialized_query = expanded_any(&shape, matrix, query); + specialized_query.extend_from_slice(tail); + if let Some(mut witness) = + useful_with_active(types, &specialized, &specialized_query, active) + { + let rest = witness.split_off(shape.fields.len()); + return Some( + std::iter::once(reconstruct(&shape, witness)) + .chain(rest) + .collect(), + ); + } + } + return None; + } + let default = default_matrix(matrix); + return useful_with_active(types, &default, tail, active) + .map(|witness| std::iter::once(head.clone()).chain(witness).collect()); + } + + let ty = head.pattern_type(); + let shape = match head { + Pattern::Array { elements, .. } => array_shape(types, ty, elements.len())?, + Pattern::Literal { literal, .. } => Shape { + ty, + head: Head::Literal(canonical_literal(literal)), + fields: Vec::new(), + }, + _ => { + let selected = head_of(head)?; + signature(types, ty)? + .into_iter() + .find(|shape| shape.head == selected)? + } + }; + let specialized = specialize(matrix, &shape); + let mut specialized_query = expand(head, &shape, matrix, query); + specialized_query.extend_from_slice(tail); + useful_with_active(types, &specialized, &specialized_query, active).map(|mut witness| { + let rest = witness.split_off(shape.fields.len()); + std::iter::once(reconstruct(&shape, witness)) + .chain(rest) + .collect() + }) +} + +/// Constructs a finite inhabitant independently of the pattern matrix. The +/// active path is keyed by type, so recursive products cannot grow a sequence +/// of distinct query states while looking for a base constructor. +fn inhabited_type>( + types: &T, + ty: Ty, + active: &mut HashSet, +) -> Option> +where + Ty: Clone + Copy + std::fmt::Debug + Eq + std::hash::Hash, +{ + let ty = types.shape(ty); + if !active.insert(ty) { + return None; + } + let result = if types.array_element(ty).is_some() { + Some(Pattern::Array { + elements: Vec::new(), + ty, + }) + } else if let Some(shapes) = signature(types, ty) { + shapes + .iter() + .find_map(|shape| inhabited_shape(types, shape, active)) + } else { + Some(Pattern::Any { ty }) + }; + active.remove(&ty); + result +} + +fn inhabited_shape>( + types: &T, + shape: &Shape, + active: &mut HashSet, +) -> Option> +where + Ty: Clone + Copy + std::fmt::Debug + Eq + std::hash::Hash, +{ + let fields = shape + .fields + .iter() + .map(|(_, ty)| inhabited_type(types, *ty, active)) + .collect::>>()?; + Some(reconstruct(shape, fields)) +} + +fn useful_array_any>( + types: &T, + matrix: &Matrix, + query: &[Pattern], + tail: &[Pattern], + ty: Ty, + element_type: Ty, + active: &mut HashSet>, +) -> EngineResult +where + Ty: Clone + Copy + std::fmt::Debug + Eq + std::hash::Hash, +{ + let lengths = matrix + .iter() + .filter_map(|row| row.first()) + .filter_map(|pattern| match pattern { + Pattern::Array { elements, .. } => Some(elements.len()), + _ => None, + }) + .collect::>(); + let mut other_length = 0; + while lengths.contains(&other_length) { + other_length += 1; + } + let default = default_matrix(matrix); + if let Some(witness) = useful_with_active(types, &default, tail, active) { + let array = Pattern::Array { + elements: (0..other_length) + .map(|_| Pattern::Any { ty: element_type }) + .collect(), + ty, + }; + return Some(std::iter::once(array).chain(witness).collect()); + } + for length in lengths { + let shape = Shape { + ty, + head: Head::ArrayLength(length), + fields: vec![(None, element_type); length], + }; + let specialized = specialize(matrix, &shape); + let mut specialized_query = expanded_any(&shape, matrix, query); + specialized_query.extend_from_slice(tail); + if let Some(mut witness) = + useful_with_active(types, &specialized, &specialized_query, active) + { + let rest = witness.split_off(length); + return Some( + std::iter::once(reconstruct(&shape, witness)) + .chain(rest) + .collect(), + ); + } + } + None +} + +fn signature>(types: &T, ty: Ty) -> Option>> +where + Ty: Clone + Copy + std::fmt::Debug + Eq + std::hash::Hash, +{ + let ty = types.shape(ty); + if let Some(rows) = types.constructors_of(ty) { + return Some( + rows.into_iter() + .map(|row| Shape { + ty, + head: Head::Constructor(row.symbol), + fields: row.fields.iter().copied().map(|ty| (None, ty)).collect(), + }) + .collect(), + ); + } + if let Some(fields) = types.record_fields(ty) { + return Some(vec![Shape { + ty, + head: Head::Record, + fields: fields + .iter() + .map(|(label, ty)| (Some(label.clone()), *ty)) + .collect(), + }]); + } + if types.boolean_type(ty) { + return Some( + [false, true] + .into_iter() + .map(|value| Shape { + ty, + head: Head::Literal(Literal::Boolean(value)), + fields: Vec::new(), + }) + .collect(), + ); + } + None +} + +fn array_shape>(types: &T, ty: Ty, length: usize) -> Option> +where + Ty: Clone + Copy + std::fmt::Debug + Eq + std::hash::Hash, +{ + let element_type = types.array_element(ty)?; + Some(Shape { + ty, + head: Head::ArrayLength(length), + fields: vec![(None, element_type); length], + }) +} + +fn head_of(pattern: &Pattern) -> Option { + match pattern { + Pattern::Constructor { symbol, .. } => Some(Head::Constructor(*symbol)), + Pattern::Record { .. } => Some(Head::Record), + Pattern::Literal { literal, .. } => Some(Head::Literal(canonical_literal(literal))), + Pattern::Array { elements, .. } => Some(Head::ArrayLength(elements.len())), + Pattern::Any { .. } => None, + } +} + +fn expand( + pattern: &Pattern, + shape: &Shape, + matrix: &Matrix, + query: &[Pattern], +) -> Vec> +where + Ty: Clone + Copy + std::fmt::Debug + Eq + std::hash::Hash, +{ + match pattern { + Pattern::Constructor { + symbol, arguments, .. + } if shape.head == Head::Constructor(*symbol) => arguments.clone(), + Pattern::Record { fields, .. } if shape.head == Head::Record => shape + .fields + .iter() + .map(|(label, ty)| { + let label = label.as_deref().expect("record shapes have field labels"); + fields + .iter() + .find(|(field, _)| field == label) + .map_or_else(|| Pattern::Any { ty: *ty }, |(_, pat)| pat.clone()) + }) + .collect(), + Pattern::Literal { literal, .. } + if shape.head == Head::Literal(canonical_literal(literal)) => + { + Vec::new() + } + Pattern::Array { elements, .. } if shape.head == Head::ArrayLength(elements.len()) => { + elements.clone() + } + Pattern::Any { .. } => expanded_any(shape, matrix, query), + _ => Vec::new(), + } +} + +fn expanded_any( + shape: &Shape, + matrix: &Matrix, + query: &[Pattern], +) -> Vec> +where + Ty: Clone + Copy + std::fmt::Debug + Eq + std::hash::Hash, +{ + let exemplar = query + .iter() + .chain(matrix.iter().filter_map(|row| row.first())) + .find(|pattern| head_of(pattern) == Some(shape.head.clone())); + shape + .fields + .iter() + .enumerate() + .map(|(index, (label, fallback_ty))| { + let ty = exemplar.and_then(|pattern| match pattern { + Pattern::Constructor { arguments, .. } if label.is_none() => { + arguments.get(index).map(Pattern::pattern_type) + } + Pattern::Array { elements, .. } if label.is_none() => { + elements.get(index).map(Pattern::pattern_type) + } + Pattern::Record { fields, .. } => fields + .iter() + .find(|(field, _)| Some(field) == label.as_ref()) + .map(|(_, pattern)| pattern.pattern_type()), + _ => None, + }); + Pattern::Any { + ty: ty.unwrap_or(*fallback_ty), + } + }) + .collect() +} + +fn specialize(matrix: &Matrix, shape: &Shape) -> Matrix +where + Ty: Clone + Copy + std::fmt::Debug + Eq + std::hash::Hash, +{ + let mut output = Vec::new(); + let candidates = matrix + .iter() + .filter_map(|row| row.first().cloned()) + .collect::>(); + for row in matrix { + let Some(head) = row.first() else { + continue; + }; + let expanded = if matches!(head, Pattern::Any { .. }) { + expanded_any(shape, matrix, &candidates) + } else if head_of(head) == Some(shape.head.clone()) { + expand(head, shape, matrix, &candidates) + } else { + continue; + }; + output.push( + expanded + .into_iter() + .chain(row[1..].iter().cloned()) + .collect(), + ); + } + output +} + +fn default_matrix(matrix: &Matrix) -> Matrix +where + Ty: Clone + Copy + std::fmt::Debug + Eq + std::hash::Hash, +{ + matrix + .iter() + .filter(|row| { + row.first() + .is_some_and(|pattern| matches!(pattern, Pattern::Any { .. })) + }) + .map(|row| row[1..].to_vec()) + .collect() +} + +fn reconstruct(shape: &Shape, arguments: Vec>) -> Pattern +where + Ty: Clone + Copy + std::fmt::Debug + Eq + std::hash::Hash, +{ + match &shape.head { + Head::Constructor(symbol) => Pattern::Constructor { + symbol: *symbol, + arguments, + ty: shape.ty, + }, + Head::Record => Pattern::Record { + fields: shape + .fields + .iter() + .zip(arguments) + .map(|((label, _), pattern)| { + (label.clone().expect("record shapes have labels"), pattern) + }) + .collect(), + ty: shape.ty, + }, + Head::Literal(value) => Pattern::Literal { + literal: value.clone(), + ty: shape.ty, + }, + Head::ArrayLength(_) => Pattern::Array { + elements: arguments, + ty: shape.ty, + }, + } +} diff --git a/crates/psrs-coverage/src/lib.rs b/crates/psrs-coverage/src/lib.rs new file mode 100644 index 00000000..54c7f8d9 --- /dev/null +++ b/crates/psrs-coverage/src/lib.rs @@ -0,0 +1,26 @@ +//! The one owner of pattern-matrix usefulness analysis. +//! +//! Coverage and first-match compilation agree on one semantic question — what +//! a set of source alternatives cannot cover — and the type checker's `Partial` +//! exhaustiveness authorization and the decision compiler's first-match +//! lowering both consume this module's answer. The engine is typed over a +//! neutral pattern algebra and a [`Types`] abstraction, so neither the type +//! checker nor the backend reconstructs the analysis for itself. +//! +//! `Partial`'s exhaustiveness payload (the official witness behind +//! `addPartialConstraint`) comes from `Analysis::witness_atoms`; every other +//! member stays erasure-neutral. + +mod analyze; +mod engine; +mod pattern; +mod render; +mod types; + +#[cfg(test)] +mod tests; + +pub use analyze::{Analysis, Branch, analyze}; +// `Ty` remains the supplying stage's own type identity; nothing here fixes one. +pub use pattern::{Literal, Pattern}; +pub use types::{ConstructorRow, Types}; diff --git a/crates/psrs-coverage/src/pattern.rs b/crates/psrs-coverage/src/pattern.rs new file mode 100644 index 00000000..62ae53b4 --- /dev/null +++ b/crates/psrs-coverage/src/pattern.rs @@ -0,0 +1,59 @@ +//! The neutral pattern algebra of coverage analysis. +//! +//! A shared [`Pattern`] is a binder-erased shape: alternatives erase their +//! variables at their owning adapter before entering analysis, so pattern +//! equality is shape equality and no stage reconstructs binder facts. Its type +//! identity `Ty` is the supplying stage's own identity — the type checker's +//! checked type ids and the core module's type-table indexes are both valid, +//! and the engine only ever compares or carries them. + +use psrs_hir::SymbolId; + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum Pattern { + Any { + ty: Ty, + }, + Literal { + literal: Literal, + ty: Ty, + }, + Array { + elements: Vec>, + ty: Ty, + }, + Constructor { + symbol: SymbolId, + arguments: Vec>, + ty: Ty, + }, + Record { + fields: Vec<(String, Pattern)>, + ty: Ty, + }, +} + +impl Pattern { + /// The type the pattern constrains. + pub fn pattern_type(&self) -> Ty { + match self { + Pattern::Any { ty } + | Pattern::Literal { ty, .. } + | Pattern::Array { ty, .. } + | Pattern::Constructor { ty, .. } + | Pattern::Record { ty, .. } => *ty, + } + } +} + +/// The neutral projection of a scalar alternative. Both consumers' native +/// literal representations map onto this one, and canonicalization equates +/// spellings the official linter's `missingCases` treats identically. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum Literal { + Integer(i32), + Number(String), + String(String), + Char(char), + Boolean(bool), +} diff --git a/crates/psrs-coverage/src/render.rs b/crates/psrs-coverage/src/render.rs new file mode 100644 index 00000000..aa9e29a3 --- /dev/null +++ b/crates/psrs-coverage/src/render.rs @@ -0,0 +1,64 @@ +//! Witness rendering: one pattern as the missing-case atom the diagnostic +//! layer prints. Constructor names come from the supplying `Types` so the +//! rendering stays qualified per stage's convention without the engine +//! knowing any module table. + +use crate::pattern::{Literal, Pattern}; +use crate::types::Types; + +pub(crate) fn render>(types: &T, pattern: &Pattern) -> String +where + Ty: Clone + Copy + std::fmt::Debug + Eq + std::hash::Hash, +{ + match pattern { + Pattern::Any { .. } => "_".to_owned(), + Pattern::Literal { literal, .. } => render_literal(literal), + Pattern::Array { elements, .. } => format!( + "[{}]", + elements + .iter() + .map(|element| render(types, element)) + .collect::>() + .join(", ") + ), + Pattern::Constructor { + symbol, arguments, .. + } => { + let name = types.constructor_name(*symbol); + if arguments.is_empty() { + name + } else { + let arguments = arguments + .iter() + .map(|argument| match argument { + Pattern::Any { .. } => "_".to_owned(), + Pattern::Constructor { arguments, .. } if arguments.is_empty() => { + render(types, argument) + } + nested => format!("({})", render(types, nested)), + }) + .collect::>() + .join(" "); + format!("{name} {arguments}") + } + } + Pattern::Record { fields, .. } => format!( + "{{ {} }}", + fields + .iter() + .map(|(label, value)| format!("{label}: {}", render(types, value))) + .collect::>() + .join(", ") + ), + } +} + +fn render_literal(literal: &Literal) -> String { + match literal { + Literal::Integer(value) => value.to_string(), + Literal::Number(value) => value.clone(), + Literal::String(value) => format!("{value:?}"), + Literal::Char(value) => format!("{value:?}"), + Literal::Boolean(value) => value.to_string(), + } +} diff --git a/crates/psrs-coverage/src/tests.rs b/crates/psrs-coverage/src/tests.rs new file mode 100644 index 00000000..d42253ab --- /dev/null +++ b/crates/psrs-coverage/src/tests.rs @@ -0,0 +1,142 @@ +//! Direct engine tests over hand-built rows, mirroring the backend coverage +//! suite's behavioral anchors. + +use crate::{Branch, ConstructorRow, Literal, Pattern, Types, analyze}; +use psrs_hir::{CaseBranchCoverage, ModuleId, SymbolId, TypeId}; + +#[derive(Default)] +struct Fixture { + constructors: std::collections::HashMap>>, + booleans: std::collections::HashSet, +} + +impl Fixture { + fn user_type(index: u32) -> TypeId { + TypeId::new(ModuleId(0), index) + } + + fn row(&mut self, type_index: u32, symbol: u32, fields: &[u32]) { + self.constructors + .entry(type_index) + .or_default() + .push(ConstructorRow { + symbol: SymbolId::new(ModuleId(0), symbol), + fields: fields.iter().map(|&f| Self::user_type(f)).collect(), + }); + } + + fn any(&self) -> Pattern { + Pattern::Any { + ty: Self::user_type(0), + } + } + + fn constructor(&self, symbol: u32, arguments: Vec>) -> Pattern { + Pattern::Constructor { + symbol: SymbolId::new(ModuleId(0), symbol), + arguments, + ty: Self::user_type(0), + } + } + + fn literal(&self, value: bool) -> Pattern { + Pattern::Literal { + literal: Literal::Boolean(value), + ty: Self::user_type(0), + } + } +} + +impl Types for Fixture { + fn shape(&self, ty: TypeId) -> TypeId { + ty + } + + fn array_element(&self, _ty: TypeId) -> Option { + None + } + + fn constructors_of(&self, ty: TypeId) -> Option>> { + self.constructors.get(&ty.index).cloned() + } + + fn record_fields(&self, _ty: TypeId) -> Option> { + None + } + + fn boolean_type(&self, ty: TypeId) -> bool { + self.booleans.contains(&ty.index) + } + + fn constructor_name(&self, symbol: SymbolId) -> String { + format!("C{index}", index = symbol.index) + } +} + +#[test] +fn a_recursive_product_reports_its_missing_nullary_constructor() { + let mut fixture = Fixture::default(); + fixture.row(0, 0, &[1, 0, 0]); + fixture.row(0, 1, &[]); + let node = fixture.constructor(0, vec![fixture.any(), fixture.any(), fixture.any()]); + let analysis = analyze(&fixture, Fixture::user_type(0), &[source(node)]); + assert!(!analysis.exhaustive, "{analysis:?}"); + assert_eq!( + analysis.first_witness_atom(), + Some("C1"), + "the nullary constructor is the remaining witness" + ); + assert!(analysis.redundant_branches.is_empty()); +} + +#[test] +fn boolean_rows_cover_in_pairs_and_a_repeat_row_is_redundant() { + let mut fixture = Fixture::default(); + fixture.booleans.insert(0); + let analysis = analyze( + &fixture, + Fixture::user_type(0), + &[ + source(fixture.literal(true)), + source(fixture.literal(true)), + source(fixture.literal(false)), + ], + ); + assert!(analysis.exhaustive, "{analysis:?}"); + assert_eq!(analysis.redundant_branches, vec![1]); +} + +#[test] +fn guarded_rows_neither_prove_exhaustiveness_nor_make_each_other_redundant() { + let mut fixture = Fixture::default(); + fixture.booleans.insert(0); + let analysis = analyze( + &fixture, + Fixture::user_type(0), + &[ + Branch { + coverage: CaseBranchCoverage::Guarded, + pattern: fixture.literal(true), + }, + Branch { + coverage: CaseBranchCoverage::Guarded, + pattern: fixture.literal(false), + }, + ], + ); + assert!(!analysis.exhaustive, "guards may fail, so no row covers"); + assert!(analysis.redundant_branches.is_empty()); + assert_eq!( + analysis.first_witness_atom(), + Some("false"), + "the uncovered alternates surface as the witness of the guard-gated case" + ); +} + +/// Shorthand for the ubiquitous source-class alternative. +fn source(pattern: Pattern) -> Branch { + Branch { + coverage: CaseBranchCoverage::Source, + pattern, + } +} diff --git a/crates/psrs-coverage/src/types.rs b/crates/psrs-coverage/src/types.rs new file mode 100644 index 00000000..867ad9f4 --- /dev/null +++ b/crates/psrs-coverage/src/types.rs @@ -0,0 +1,47 @@ +//! The type facts analysis needs, supplied by each participating stage. +//! +//! One `Types` instance backs one whole analysis. The type checker supplies the +//! checked environment's constructor rows and record fields; the backend +//! supplies the core module's equivalents. Each method carries its own caller's +//! resolution step — walking an application spine, decoding alias shapes — so +//! the engine never reads one stage's internal representation. An incomplete, +//! unknown, or uninhabited shape answers `None` and analysis then uses the +//! default matrix path, matching the engine's documented policy. + +use psrs_hir::SymbolId; + +/// One data constructor's shape, in the coverage algebra. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ConstructorRow { + pub symbol: SymbolId, + pub fields: Vec, +} + +/// The type facts one analysis consumes. +pub trait Types { + /// The analyzed type identity after removing a quantified prefix, where the + /// supplying stage owns such a pass. Given the same identity, the answer is + /// stable, so the engine may strip once and query repeatedly. + fn shape(&self, ty: Ty) -> Ty; + + /// The element type of an `Array` application, if this is one. + fn array_element(&self, ty: Ty) -> Option; + + /// The complete constructor rows of a user data type. `None` means the + /// resolved head is not a user data constructor group known complete. An + /// uninhabited user type also answers `None`, so a wildcard matrix + /// handles it without inventing rows for an empty set. + fn constructors_of(&self, ty: Ty) -> Option>>; + + /// Fields guaranteed by a checked record type, if this is one. Every + /// analyzed pattern field must belong to this set. An open tail may add + /// fields that record patterns do not inspect; suppliers need not invent + /// those unknown fields. Closed types supply their complete field set. + fn record_fields(&self, ty: Ty) -> Option>; + + /// Whether this type is the `Boolean` constructor. + fn boolean_type(&self, ty: Ty) -> bool; + + /// The name the witness renderer prints for a data constructor. + fn constructor_name(&self, symbol: SymbolId) -> String; +} diff --git a/crates/psrs-driver/src/tests/adts.rs b/crates/psrs-driver/src/tests/adts.rs index 740d3acd..9417e545 100644 --- a/crates/psrs-driver/src/tests/adts.rs +++ b/crates/psrs-driver/src/tests/adts.rs @@ -27,16 +27,14 @@ fn reports_a_missing_nested_constructor_as_a_coverage_witness() { module Main where data Inner = First | Second data Outer = Wrap Inner +main :: Int main = case Wrap First of Wrap First -> 1 "; let errors = compile_source("Main.purs", source).expect_err("non-exhaustive nested case"); let diagnostic = errors .iter() - .find(|error| { - error.stage == "P8 closure conversion" - && error.message.contains("missing pattern Wrap Second") - }) + .find(|error| error.stage == "P5 typecheck" && error.message.contains("Wrap Second")) .expect("coverage diagnostic should include its missing-pattern witness"); let span = &source[diagnostic.span.start as usize..diagnostic.span.end as usize]; assert_eq!(span, "case Wrap First of\n Wrap First -> 1\n"); diff --git a/crates/psrs-driver/src/tests/declaration_calls.rs b/crates/psrs-driver/src/tests/declaration_calls.rs index 1c3a411d..6ba49506 100644 --- a/crates/psrs-driver/src/tests/declaration_calls.rs +++ b/crates/psrs-driver/src/tests/declaration_calls.rs @@ -198,3 +198,96 @@ main = case hold [42] of assert_eq!(output.status.code(), Some(42), "{output:?}"); assert!(output.stderr.is_empty(), "{output:?}"); } + +#[test] +fn inferred_partial_argument_patterns_compose_with_unsafe_partial() { + let source = r#"module Main where +import Partial.Unsafe (unsafePartial) +f x [y, z] = x y z +add :: Int -> Int -> Int +add a b = a + b +main :: Int +main = unsafePartial (f add [20, 22]) +"#; + let source = source.replace("import Partial", "import Prelude\nimport Partial"); + let Some(output) = run_with_wasmtime(&source) else { + return; + }; + assert_eq!(output.status.code(), Some(42), "{output:?}"); +} + +#[test] +fn annotated_partial_case_reports_a_missing_witness_at_typechecking() { + let source = "module Main where\nf :: Boolean -> Int\nf true = 42\nmain = f true\n"; + let errors = crate::compile_source("Main.purs", source).expect_err("missing Partial"); + let error = errors + .iter() + .find(|error| { + error + .message + .contains("A case expression could not be determined to cover all inputs.") + }) + .expect("coverage diagnostic"); + assert!(error.message.contains("false"), "{error:?}"); + assert!( + error + .message + .contains("Alternatively, add a Partial constraint"), + "{error:?}" + ); + assert_eq!(error.stage, "P5 typecheck"); +} + +#[test] +fn coverage_preserves_nested_parameterized_constructor_fields() { + let source = r#"module Main where +data Box a = Box a +f :: Box Boolean -> Int +f (Box true) = 42 +f (Box false) = 7 +main :: Int +main = f (Box true) +"#; + let Some(output) = run_with_wasmtime(source) else { + return; + }; + assert_eq!(output.status.code(), Some(42), "{output:?}"); +} + +#[test] +fn recursive_newtype_storage_is_erased_at_calls_and_patterns() { + let source = r#"module Main where +import Unsafe.Coerce (unsafeCoerce) +newtype Loop = Loop Loop +unwrap :: Loop -> Loop +unwrap (Loop value) = value +main :: Int +main = unsafeCoerce (unwrap (unsafeCoerce 42)) +"#; + let Some(output) = run_with_wasmtime(source) else { + return; + }; + assert_eq!(output.status.code(), Some(42), "{output:?}"); +} + +#[test] +fn coverage_resolves_every_nested_pattern_type_after_branch_unification() { + let source = r#"module Main where +import Prelude +data Step a = Nil | Cons a (Step a) +drop :: forall a. Int -> Step a -> Step a +drop = go + where + go 0 xs = xs + go _ Nil = Nil + go n (Cons _ xs) = go (n - 1) xs +main :: Int +main = case drop 1 (Cons 7 (Cons 42 Nil)) of + Cons value _ -> value + _ -> 0 +"#; + let Some(output) = run_with_wasmtime(source) else { + return; + }; + assert_eq!(output.status.code(), Some(42), "{output:?}"); +} diff --git a/crates/psrs-driver/src/tests/dictionary_audit/fixtures/evidence.rs b/crates/psrs-driver/src/tests/dictionary_audit/fixtures/evidence.rs index 35eaf70d..fb3dab19 100644 --- a/crates/psrs-driver/src/tests/dictionary_audit/fixtures/evidence.rs +++ b/crates/psrs-driver/src/tests/dictionary_audit/fixtures/evidence.rs @@ -156,6 +156,7 @@ pub(crate) fn dictionary_module() -> (thir::Module, SymbolId) { external_types: Vec::new(), types, newtype_ids: Vec::new(), + ordinary_data_ids: Vec::new(), opaque_ids: Vec::new(), callable_types: Vec::new(), constructors: Vec::new(), @@ -328,6 +329,7 @@ pub(crate) fn escaping_method_module() -> (thir::Module, SymbolId) { external_types: Vec::new(), types, newtype_ids: Vec::new(), + ordinary_data_ids: Vec::new(), opaque_ids: Vec::new(), callable_types: Vec::new(), constructors: Vec::new(), diff --git a/crates/psrs-driver/src/tests/dictionary_audit/fixtures/generic.rs b/crates/psrs-driver/src/tests/dictionary_audit/fixtures/generic.rs index b5ed4b45..dd865f6a 100644 --- a/crates/psrs-driver/src/tests/dictionary_audit/fixtures/generic.rs +++ b/crates/psrs-driver/src/tests/dictionary_audit/fixtures/generic.rs @@ -126,6 +126,7 @@ pub(crate) fn erased_dictionary_module() -> (thir::Module, SymbolId) { external_types: Vec::new(), types, newtype_ids: Vec::new(), + ordinary_data_ids: Vec::new(), opaque_ids: Vec::new(), callable_types: Vec::new(), constructors: Vec::new(), @@ -245,6 +246,7 @@ pub(crate) fn polymorphic_method_module() -> (thir::Module, SymbolId) { external_types: Vec::new(), types, newtype_ids: Vec::new(), + ordinary_data_ids: Vec::new(), opaque_ids: Vec::new(), callable_types: Vec::new(), constructors: Vec::new(), diff --git a/crates/psrs-driver/src/tests/dictionary_audit/fixtures/methods.rs b/crates/psrs-driver/src/tests/dictionary_audit/fixtures/methods.rs index 60acdd13..fc76d137 100644 --- a/crates/psrs-driver/src/tests/dictionary_audit/fixtures/methods.rs +++ b/crates/psrs-driver/src/tests/dictionary_audit/fixtures/methods.rs @@ -87,6 +87,7 @@ pub(crate) fn default_method_module() -> (thir::Module, SymbolId) { external_types: Vec::new(), types, newtype_ids: Vec::new(), + ordinary_data_ids: Vec::new(), opaque_ids: Vec::new(), callable_types: Vec::new(), constructors: Vec::new(), @@ -339,6 +340,7 @@ pub(crate) fn recursive_instance_module() -> (thir::Module, SymbolId) { external_types: Vec::new(), types, newtype_ids: Vec::new(), + ordinary_data_ids: Vec::new(), opaque_ids: Vec::new(), callable_types: Vec::new(), constructors: Vec::new(), diff --git a/crates/psrs-driver/src/tests/dictionary_audit/fixtures/ordering.rs b/crates/psrs-driver/src/tests/dictionary_audit/fixtures/ordering.rs index 43ce60d2..cc810c02 100644 --- a/crates/psrs-driver/src/tests/dictionary_audit/fixtures/ordering.rs +++ b/crates/psrs-driver/src/tests/dictionary_audit/fixtures/ordering.rs @@ -172,6 +172,7 @@ pub(crate) fn ordered_dictionaries_module(second_accepts: bool) -> (thir::Module external_types: Vec::new(), types, newtype_ids: Vec::new(), + ordinary_data_ids: Vec::new(), opaque_ids: Vec::new(), callable_types: Vec::new(), constructors: Vec::new(), @@ -365,6 +366,7 @@ pub(crate) fn shared_dictionary_module() -> (thir::Module, SymbolId) { external_types: Vec::new(), types, newtype_ids: Vec::new(), + ordinary_data_ids: Vec::new(), opaque_ids: Vec::new(), callable_types: Vec::new(), constructors: Vec::new(), diff --git a/crates/psrs-driver/src/tests/dictionary_audit/negative.rs b/crates/psrs-driver/src/tests/dictionary_audit/negative.rs index 28b3fae8..55bb2bd7 100644 --- a/crates/psrs-driver/src/tests/dictionary_audit/negative.rs +++ b/crates/psrs-driver/src/tests/dictionary_audit/negative.rs @@ -58,6 +58,7 @@ fn module_with(bad: thir::Declaration, span: TextRange) -> thir::Module { external_types: Vec::new(), types: types.list(), newtype_ids: Vec::new(), + ordinary_data_ids: Vec::new(), opaque_ids: Vec::new(), callable_types: Vec::new(), constructors: Vec::new(), diff --git a/crates/psrs-driver/src/tests/guards.rs b/crates/psrs-driver/src/tests/guards.rs index f61b48f0..09e24163 100644 --- a/crates/psrs-driver/src/tests/guards.rs +++ b/crates/psrs-driver/src/tests/guards.rs @@ -150,13 +150,14 @@ fn multi_scrutinee_boolean_patterns_lower_and_prove_coverage() { }; assert_eq!(output.status.code(), Some(13), "{output:?}"); - let partial = "module Main where\nmain = case true, false of\n true, false -> 1\n"; + let partial = + "module Main where\nmain :: Int\nmain = case true, false of\n true, false -> 1\n"; let errors = compile_source("Main.purs", partial) .expect_err("a Boolean product case missing rows remains partial"); assert!( - errors - .iter() - .any(|error| error.message.contains("non-exhaustive case")), + errors.iter().any(|error| error + .message + .contains("could not be determined to cover all inputs")), "{errors:?}" ); } @@ -313,27 +314,29 @@ fn later_guard_clause_binders_are_renamed_in_each_fallthrough_copy() { #[test] fn guarded_rows_do_not_claim_unconditional_coverage() { - let source = "module Main where\nimport Prelude\nread n\n | n > 0 = n\nmain = read 1\n"; + let source = "module Main where\nimport Prelude\nread :: Int -> Int\nread n\n | n > 0 = n\nmain = read 1\n"; let errors = compile_source("Main.purs", source) .expect_err("a partial guarded equation is not exhaustive"); assert_eq!( errors .iter() - .filter(|error| error.message.contains("non-exhaustive case")) + .filter(|error| error + .message + .contains("could not be determined to cover all inputs")) .count(), 1, "{errors:?}" ); let boolean_case = - "module Main where\nmain = case true of\n true | false -> 1\n false -> 2\n"; + "module Main where\nmain :: Int\nmain = case true of\n true | false -> 1\n false -> 2\n"; let errors = compile_source("Main.purs", boolean_case) .expect_err("a failing Boolean guard must not make its case exhaustive"); - assert!( - errors - .iter() - .any(|error| error.message.contains("non-exhaustive case")) - ); + assert!(errors.iter().any(|error| { + error + .message + .contains("could not be determined to cover all inputs") + })); let exhaustive = "module Main where\nread n\n | true = n\nmain = read 1\n"; let artifact = @@ -385,7 +388,7 @@ fn user_defined_false_otherwise_does_not_prove_guard_coverage() { let constants = "module Boolean.Constants (otherwise) where\notherwise :: Boolean\notherwise = false\n"; let alias = "module Boolean.Alias (otherwise) where\nimport Boolean.Constants (otherwise)\n"; - let main = "module Main where\nimport Boolean.Alias (otherwise)\nread n\n | otherwise = n\nmain = read 1\n"; + let main = "module Main where\nimport Boolean.Alias (otherwise)\nread :: Int -> Int\nread n\n | otherwise = n\nmain = read 1\n"; let errors = compile_program_sources_with_prelude(&[ ("Boolean.Constants.purs", constants), ("Boolean.Alias.purs", alias), @@ -395,7 +398,10 @@ fn user_defined_false_otherwise_does_not_prove_guard_coverage() { assert_eq!( errors .iter() - .filter(|error| error.diagnostic.message.contains("non-exhaustive case")) + .filter(|error| error + .diagnostic + .message + .contains("could not be determined to cover all inputs")) .count(), 1, "{errors:?}" @@ -406,7 +412,7 @@ fn user_defined_false_otherwise_does_not_prove_guard_coverage() { fn cross_module_true_alias_is_a_verified_unconditional_guard() { let constants = "module Boolean.Constants (truth) where\ntruth :: Boolean\ntruth = true\n"; let alias = "module Boolean.Alias (otherwise) where\nimport Boolean.Constants (truth)\notherwise :: Boolean\notherwise = truth\n"; - let main = "module Main where\nimport Boolean.Alias (otherwise)\nread n\n | otherwise = n\nmain = read 11\n"; + let main = "module Main where\nimport Boolean.Alias (otherwise)\nread :: Int -> Int\nread n\n | otherwise = n\nmain = read 11\n"; let artifact = compile_program_sources_with_prelude(&[ ("Boolean.Constants.purs", constants), ("Boolean.Alias.purs", alias), diff --git a/crates/psrs-driver/src/tests/let_constraints.rs b/crates/psrs-driver/src/tests/let_constraints.rs index 9ef9c6ea..cbe5809c 100644 --- a/crates/psrs-driver/src/tests/let_constraints.rs +++ b/crates/psrs-driver/src/tests/let_constraints.rs @@ -148,8 +148,6 @@ class Ord a <= BoundedEnum a where data Maybe a = Nothing | Just a data Tuple a b = Tuple a b -class Partial - fromJust :: forall a. Partial => Maybe a -> a fromJust (Just value) = value diff --git a/crates/psrs-driver/src/tests/mod.rs b/crates/psrs-driver/src/tests/mod.rs index af585f6a..6b1d92fa 100644 --- a/crates/psrs-driver/src/tests/mod.rs +++ b/crates/psrs-driver/src/tests/mod.rs @@ -36,6 +36,7 @@ mod number_trunc; mod operators; mod partial_application; mod primitive_foreign; +mod record_storage; mod runtime_storage; mod scalars; mod semigroup; diff --git a/crates/psrs-driver/src/tests/native_representation/command.rs b/crates/psrs-driver/src/tests/native_representation/command.rs index 8b8ce07c..c8d6cfdb 100644 --- a/crates/psrs-driver/src/tests/native_representation/command.rs +++ b/crates/psrs-driver/src/tests/native_representation/command.rs @@ -19,17 +19,18 @@ main = do } #[test] -fn the_native_command_runner_propagates_traps_and_rejects_a_different_result_type() { +fn the_native_command_runner_propagates_traps_from_non_unit_actions() { let source = r#"module Main where import Prelude import Effect (Effect) import Test.Assert (assert) import WASI.Console (log) -main :: Effect Unit +main :: Effect Int main = do log "before" assert false log "after" + pure 42 "#; let Some(output) = super::super::run_program_with_prelude_wasmtime(&[("Main.purs", source)]) else { @@ -37,7 +38,31 @@ main = do }; assert!(!output.status.success(), "{output:?}"); assert_eq!(output.stdout, b"before\n", "{output:?}"); - let invalid = "module Main where\nimport Prelude\nimport Effect (Effect)\nmain :: Effect Int\nmain = pure 42\n"; +} + +#[test] +fn the_native_command_runner_discards_a_non_unit_result_after_execution() { + let source = r#"module Main where +import Prelude +import Effect (Effect) +import WASI.Console (log) +main :: Effect Int +main = do + log "result" + pure 42 +"#; + let Some(output) = super::super::run_program_with_prelude_wasmtime(&[("Main.purs", source)]) + else { + return; + }; + assert_eq!(output.status.code(), Some(0), "{output:?}"); + assert_eq!(output.stdout, b"result\n", "{output:?}"); + assert!(output.stderr.is_empty(), "{output:?}"); +} + +#[test] +fn the_native_command_runner_rejects_an_unrelated_carrier() { + let invalid = "module Main where\nmain :: Array Int\nmain = [42]\n"; let errors = crate::compile_program_sources_with_prelude(&[("Main.purs", invalid)]) .expect_err("the ordinary runner accepts only its declared input"); assert!( diff --git a/crates/psrs-driver/src/tests/native_representation/region.rs b/crates/psrs-driver/src/tests/native_representation/region.rs index f00264ab..54ed73ca 100644 --- a/crates/psrs-driver/src/tests/native_representation/region.rs +++ b/crates/psrs-driver/src/tests/native_representation/region.rs @@ -32,7 +32,7 @@ main = test } #[test] -fn run_rejects_an_escaping_st_action() { +fn run_rejects_an_st_action_at_a_scalar_result() { rejects( r#"module Main where import Prelude @@ -47,6 +47,29 @@ leak = do main :: Int main = ST.run leak +"#, + "TypesDoNotUnify", + ); +} + +#[test] +fn run_rejects_an_inferred_escaping_st_action() { + rejects( + r#"module Main where +import Prelude +import Control.Monad.ST (ST) +import Control.Monad.ST as ST +import Control.Monad.ST.Ref as STRef + +leak :: forall r. ST r (ST r Int) +leak = do + ref <- STRef.new 0 + pure (STRef.read ref) + +escaped = ST.run leak + +main :: Int +main = 0 "#, "EscapedSkolem", ); diff --git a/crates/psrs-driver/src/tests/record_storage.rs b/crates/psrs-driver/src/tests/record_storage.rs new file mode 100644 index 00000000..09e98e7f --- /dev/null +++ b/crates/psrs-driver/src/tests/record_storage.rs @@ -0,0 +1,220 @@ +use super::*; + +const CARRIER: &str = r#"module Carrier (Cell, pack, unpack) where +foreign import "psrs:intrinsic#__psrs_unsafe_coerce" unsafeCoerce :: forall a b. a -> b +data Cell +pack :: forall a. a -> Cell +pack = unsafeCoerce +unpack :: forall a. Cell -> a +unpack = unsafeCoerce +"#; + +const PARAMETERIZED_CARRIER: &str = r#"module Carrier (Cell, pack, unpack) where +foreign import "psrs:intrinsic#__psrs_unsafe_coerce" unsafeCoerce :: forall a b. a -> b +data Cell a +pack :: forall a. a -> Cell Int +pack = unsafeCoerce +unpack :: forall a. Cell Int -> a +unpack = unsafeCoerce +"#; + +const PARAMETERIZED_STORAGE: &str = r#"module Storage where +import Carrier (Cell) +foreign import "psrs:record-storage#length" size :: forall r. Record r -> Int +foreign import "psrs:record-storage#payload_at" payloadAt :: forall r. Int -> Record r -> Cell Int +foreign import "psrs:record-storage#build" build :: forall r. Array String -> Array (Cell Int) -> Record r +"#; + +const STORAGE: &str = r#"module Storage where +import Carrier (Cell) +foreign import "psrs:record-storage#length" size :: forall r. Record r -> Int +foreign import "psrs:record-storage#key_at" keyAt :: forall r. Int -> Record r -> String +foreign import "psrs:record-storage#payload_at" payloadAt :: forall r. Int -> Record r -> Cell +foreign import "psrs:record-storage#build" build :: forall r. Array String -> Array Cell -> Record r +foreign import "psrs:runtime-storage#array_write" write :: forall a. Array a -> Int -> a -> Unit +"#; + +#[test] +fn record_primitives_accept_an_imported_applied_constructorless_slot() { + let main = r#"module Main where +import Carrier (pack, unpack) +import Storage +foreign import "psrs:intrinsic#intEq" eq :: Int -> Int -> Boolean +main :: Int +main = + let record = build ["value"] [pack 42] + in if eq (size record) 1 + then unpack (payloadAt 0 record) :: Int + else 0 +"#; + let Some(output) = run_program_with_wasmtime(&[ + ("Carrier.purs", PARAMETERIZED_CARRIER), + ("Storage.purs", PARAMETERIZED_STORAGE), + ("Main.purs", main), + ]) else { + return; + }; + assert_eq!(output.status.code(), Some(42), "{output:?}"); +} + +#[test] +fn record_primitives_keep_imported_slots_and_snapshot_build_inputs() { + let main = r#"module Main where +import Carrier (pack, unpack) +import Storage +foreign import "psrs:intrinsic#intEq" eq :: Int -> Int -> Boolean +invoke :: forall r. (Record r -> Int) -> Record r -> Int +invoke f value = f value +main :: Int +main = + let keys = ["a", "nested", "value"] + cells = [pack 17, pack { inner: 42 }, pack (\x -> x)] + record = build keys cells + changedKeys = write keys 1 "corrupt" + changedCells = write cells 1 (pack { inner: 1 }) + nested = unpack (payloadAt 1 record) :: { inner :: Int } + function = unpack (payloadAt 2 record) :: Int -> Int + in if eq (invoke size record) 3 + then if eq (arrayLength (stringToBytes (keyAt 1 record))) 6 + then function nested.inner + else 2 + else 3 +"#; + let Some(output) = run_program_with_wasmtime(&[ + ("Carrier.purs", CARRIER), + ("Storage.purs", STORAGE), + ("Main.purs", main), + ]) else { + return; + }; + assert_eq!(output.status.code(), Some(42), "{output:?}"); +} + +#[test] +fn record_primitives_adapt_closed_records_and_order_unicode_labels() { + let main = r#"module Main where +import Carrier (Cell, pack, unpack) +import Storage +foreign import "psrs:intrinsic#intEq" eq :: Int -> Int -> Boolean +foreign import "psrs:record-storage#length" closedSize :: { value :: Int } -> Int +foreign import "psrs:record-storage#build" buildClosed :: Array String -> Array Cell -> { value :: Int } +main :: Int +main = + let closed = buildClosed ["value"] [pack 42] + unicode = build ["", "a", "a😀", "b", "😀"] + [pack 1, pack 2, pack 3, pack 4, pack 5] + empty = build [] [] + in if eq (intAdd closed.value (closedSize closed)) 43 + then if eq (size unicode) 5 + then if eq (arrayLength (stringToBytes (keyAt 2 unicode))) 5 + then if eq (arrayLength (stringToBytes (keyAt 4 unicode))) 4 + then if eq (unpack (payloadAt 2 unicode) :: Int) 3 + then if eq (size empty) 0 then 42 else 1 + else 1 + else 1 + else 1 + else 1 + else 1 +"#; + let Some(output) = run_program_with_wasmtime(&[ + ("Carrier.purs", CARRIER), + ("Storage.purs", STORAGE), + ("Main.purs", main), + ]) else { + return; + }; + assert_eq!(output.status.code(), Some(42), "{output:?}"); +} + +#[test] +fn record_build_rejects_bad_parallel_arrays_and_label_order() { + let main = [ + "size (build [\"a\", \"a\"] [pack 1, pack 2])", + "size (build [\"b\", \"a\"] [pack 1, pack 2])", + "size (build [\"a\"] [])", + ]; + for body in main { + let source = format!( + "module Main where\nimport Carrier (pack)\nimport Storage\nmain :: Int\nmain = {body}\n" + ); + let Some(output) = run_program_with_wasmtime(&[ + ("Carrier.purs", CARRIER), + ("Storage.purs", STORAGE), + ("Main.purs", &source), + ]) else { + return; + }; + assert!(!output.status.success(), "{body}: {output:?}"); + assert!( + String::from_utf8_lossy(&output.stderr).contains("unreachable"), + "{body}: {output:?}" + ); + } +} + +#[test] +fn record_reads_trap_on_negative_and_out_of_range_indices() { + let main = [ + "arrayLength (stringToBytes (keyAt (intNeg 1) (build [\"a\"] [pack 42])))", + "arrayLength (stringToBytes (keyAt 1 (build [\"a\"] [pack 42])))", + "if eq (unpack (payloadAt 1 (build [\"a\"] [pack 42])) :: Int) 42 then 1 else 0", + ]; + for body in main { + let source = format!( + "module Main where\nimport Carrier (pack, unpack)\nimport Storage\nforeign import \"psrs:intrinsic#intEq\" eq :: Int -> Int -> Boolean\nmain :: Int\nmain = {body}\n" + ); + let Some(output) = run_program_with_wasmtime(&[ + ("Carrier.purs", CARRIER), + ("Storage.purs", STORAGE), + ("Main.purs", &source), + ]) else { + return; + }; + assert!(!output.status.success(), "{body}: {output:?}"); + assert!( + String::from_utf8_lossy(&output.stderr).contains("unreachable"), + "{body}: {output:?}" + ); + } +} + +#[test] +fn record_storage_validates_unused_source_contracts_before_erasure() { + for (module, export, expected) in [ + ( + "module Storage where\nforeign import \"psrs:record-storage#length\" op :: forall r. Record r -> Number\n", + "length", + "record primitive signature expects Int", + ), + ( + "module Storage where\nforeign import \"psrs:record-storage#payload_at\" op :: forall r a. Int -> Record r -> a\n", + "payload_at", + "record primitive Slot must be a declared constructorless ordinary data type", + ), + ( + "module Storage where\ndata Cell = Cell\nforeign import \"psrs:record-storage#payload_at\" op :: forall r. Int -> Record r -> Cell\n", + "payload_at", + "record primitive Slot must be a declared constructorless ordinary data type", + ), + ( + "module Storage where\nimport Carrier (Cell)\nforeign import \"psrs:record-storage#build\" op :: Array Int -> Array Cell -> Record ()\n", + "build", + "record primitive signature expects Array String", + ), + ] { + let main = "module Main where\nimport Storage\nmain :: Int\nmain = 42\n"; + let errors = compile_program_sources(&[ + ("Carrier.purs", CARRIER), + ("Storage.purs", module), + ("Main.purs", main), + ]) + .expect_err("unused invalid record providers must reject during source linking"); + assert!( + errors.iter().any(|error| { + error.diagnostic.stage == "P8 runtime linking" + && error.diagnostic.message.contains(expected) + }), + "{export}: {errors:?}" + ); + } +} diff --git a/crates/psrs-driver/src/tests/records.rs b/crates/psrs-driver/src/tests/records/mod.rs similarity index 98% rename from crates/psrs-driver/src/tests/records.rs rename to crates/psrs-driver/src/tests/records/mod.rs index 4fd72b42..1f9918a4 100644 --- a/crates/psrs-driver/src/tests/records.rs +++ b/crates/psrs-driver/src/tests/records/mod.rs @@ -1,4 +1,5 @@ use super::*; +mod open_rows; #[test] fn explicit_update_expression_uses_the_local_record_even_when_labels_match() { @@ -309,13 +310,10 @@ swapped record = same record main = swapped { y: true, x: 1 } "; check_source("Main.purs", source).expect("open rows should type check"); - let errors = compile_source("Main.purs", source).expect_err("open rows have no runtime layout"); - assert!( - errors.iter().any(|error| error - .message - .contains("open record rows have no runtime layout")), - "{errors:?}" - ); + let Some(output) = run_with_wasmtime(source) else { + return; + }; + assert_eq!(output.status.code(), Some(1), "{output:?}"); } #[test] diff --git a/crates/psrs-driver/src/tests/records/open_rows/composition.rs b/crates/psrs-driver/src/tests/records/open_rows/composition.rs new file mode 100644 index 00000000..f9049c49 --- /dev/null +++ b/crates/psrs-driver/src/tests/records/open_rows/composition.rs @@ -0,0 +1,125 @@ +use super::*; + +#[test] +fn local_open_row_closures_execute_without_core_specialization() { + let source = r#"module Main where +main :: Int +main = + let captured = 2 + select :: forall r. { value :: Int | r } -> Int + select { value: x } = intAdd x captured + first = select { value: 20, left: true } + second = select { value: 18, right: [1,2] } + in intAdd first second +"#; + let Some(()) = wasmtime_available() else { + return; + }; + let core = crate::program::lower_program_to_core(&[("Main.purs", source)]) + .expect("source checks and lowers to unoptimized Core"); + let input = psrs_backend::cc::lower_module(core).expect("generic local row lowers directly"); + let readers = input + .cc + .functions + .iter() + .filter(|function| { + function.assignments.iter().any(|assignment| { + matches!( + assignment.kind, + psrs_backend::cc::AssignmentKind::Record { + operation: psrs_backend::cc::RecordOperation::Find { .. }, + .. + } + ) + }) + }) + .collect::>(); + assert_eq!(readers.len(), 1, "one generic row reader serves both uses"); + let artifact = psrs_backend::compile_cc(input).expect("unoptimized generic CC emits"); + let id = WASM_ARTIFACT_COUNTER.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!("psrs-or07-{}-{id}.wasm", std::process::id())); + std::fs::write(&path, artifact.wasm).unwrap(); + let output = std::process::Command::new("wasmtime") + .arg("run") + .arg(&path) + .output() + .unwrap(); + std::fs::remove_file(path).unwrap(); + assert_eq!(output.status.code(), Some(42), "{output:?}"); + assert!(output.stderr.is_empty(), "{output:?}"); +} + +#[test] +fn imported_recursive_updates_and_nested_callable_payloads_keep_unknown_tails() { + let library = r#"module Rows where +data Box a = Box a +update :: forall a r. { value :: a | r } -> { value :: Int | r } +update record = record { value = 21 } +repeat :: forall r. Int -> { value :: Int | r } -> { value :: Int | r } +repeat n record = if intEq n 0 then record + else repeat (intSub n 1) (record { value = intAdd record.value 1 }) +read :: forall r. Box (Array { value :: Int | r }) -> Int +read (Box values) = case arrayIndex values 0 of + { value } -> value +"#; + let main = r#"module Main where +import Rows +main :: Int +main = let original = { a: "kept", value: false, z: { callback: \x -> intAdd x 1 } } + result = repeat 20 (update original) + in if original.value then 1 + else case result.a of + "kept" -> result.z.callback (read (Box [result])) + _ -> 2 +"#; + let Some(output) = run_program_with_wasmtime(&[("Rows.purs", library), ("Main.purs", main)]) + else { + return; + }; + assert_eq!(output.status.code(), Some(42), "{output:?}"); + assert!(output.stderr.is_empty(), "{output:?}"); +} + +#[test] +fn a_local_row_polymorphic_reader_is_passed_at_an_open_use_type() { + let source = r#"module Main where +apply :: forall r. ({ value :: Int | r } -> Int) -> { value :: Int | r } -> Int +apply f record = f record +read :: forall r. Int -> { value :: Int | r } -> Int +read captured record = + let select :: forall s. { value :: Int | s } -> Int + select { value } = intAdd value captured + in apply select record +main :: Int +main = intAdd (read 1 { a: false, value: 19 }) (read 2 { value: 20, z: "kept" }) +"#; + let Some(output) = run_with_wasmtime(source) else { + return; + }; + assert_eq!(output.status.code(), Some(42), "{output:?}"); + assert!(output.stderr.is_empty(), "{output:?}"); +} + +#[test] +fn captured_recursive_local_row_updates_preserve_each_instantiations_tail() { + let source = r#"module Main where +increment :: forall r. Int -> { value :: Int | r } -> { value :: Int | r } +increment amount record = + let step = 2 + go :: forall s. Int -> { value :: Int | s } -> { value :: Int | s } + go n current = if intEq n 0 then current + else go (intSub n 1) (current { value = intAdd current.value step }) + in go amount record +main :: Int +main = let first = increment 10 { a: "kept", value: 0 } + second = increment 10 { value: 2, z: [false] } + in case first.a of + "kept" -> if arrayIndex second.z 0 then 1 else intAdd first.value second.value + _ -> 2 +"#; + let Some(output) = run_with_wasmtime(source) else { + return; + }; + assert_eq!(output.status.code(), Some(42), "{output:?}"); + assert!(output.stderr.is_empty(), "{output:?}"); +} diff --git a/crates/psrs-driver/src/tests/records/open_rows/interactions.rs b/crates/psrs-driver/src/tests/records/open_rows/interactions.rs new file mode 100644 index 00000000..bd24b3c8 --- /dev/null +++ b/crates/psrs-driver/src/tests/records/open_rows/interactions.rs @@ -0,0 +1,131 @@ +use super::*; + +// OR-04/OR-05 combination coverage: interactions the earlier open-row tests do +// not exercise. All realize through the labelled record store without any +// specialization. + +#[test] +fn an_erased_polymorphic_transport_keeps_the_record_metadata() { + // The design invariant for bare erased slots: passing a record through + // `forall a. a -> a` must not discard the label/value sequence, so the + // value still opens at a later open-row use. + let source = r#"module Main where +read :: forall r. { value :: Int | r } -> Int +read record = case record of { value: v } -> v +wrap :: forall a. a -> a +wrap x = x +main :: Int +main = if intEq (read (wrap { z: true, value: 42 })) 42 then 42 else 1 +"#; + let Some(output) = run_with_wasmtime(source) else { + return; + }; + assert_eq!(output.status.code(), Some(42), "{output:?}"); +} + +#[test] +fn a_record_pattern_inside_a_constructor_pattern_selects_open_row_labels() { + let source = r#"module Main where +data Maybe a = Nothing | Just a +readJust :: forall r. Maybe { value :: Int | r } -> Int +readJust (Just { value }) = value +readJust Nothing = 0 +other :: forall s. Maybe { kept :: Boolean | s } -> Boolean +other (Just { kept }) = kept +other Nothing = false +main :: Int +main = if intEq (readJust (Just { z: true, value: 40 })) 40 + then if other (Just { kept: true, elsewhere: 9 }) then 42 else 2 + else 1 +"#; + let Some(output) = run_with_wasmtime(source) else { + return; + }; + assert_eq!(output.status.code(), Some(42), "{output:?}"); +} + +#[test] +fn two_record_pattern_columns_select_their_own_labels() { + let source = r#"module Main where +combine :: forall r s. { value :: Int | r } -> { flag :: Boolean | s } -> Int +combine { value } { flag } = if flag then value else 2 +main :: Int +main = intAdd (combine { a: "x", value: 40 } { b: 9, flag: true }) + (combine { value: 20 } { flag: false }) +"#; + let Some(output) = run_with_wasmtime(source) else { + return; + }; + assert_eq!(output.status.code(), Some(42), "{output:?}"); +} + +#[test] +fn a_wildcard_field_label_does_not_bind_or_consume_the_tail() { + let source = r#"module Main where +read :: forall r. { ignored :: Boolean, value :: Int | r } -> Int +read record = case record of { ignored: _, value: v } -> v +main :: Int +main = if intEq (read { ignored: true, z: "x", value: 42 }) 42 then 42 else 1 +"#; + let Some(output) = run_with_wasmtime(source) else { + return; + }; + assert_eq!(output.status.code(), Some(42), "{output:?}"); +} + +#[test] +fn a_callable_field_extracted_by_a_pattern_applies_immediately() { + let source = r#"module Main where +use :: forall r. { f :: Int -> Int | r } -> Int +use record = case record of { f: g } -> g 3 +main :: Int +main = if intEq (use { extra: [1], f: \x -> intAdd x 39 }) 42 then 42 else 1 +"#; + let Some(output) = run_with_wasmtime(source) else { + return; + }; + assert_eq!(output.status.code(), Some(42), "{output:?}"); +} + +#[test] +fn a_guard_after_a_record_pattern_reads_the_bound_label() { + let source = r#"module Main where +read :: forall r. { value :: Int | r } -> Int +read record = case record of + { value: v } | intEq v 42 -> 42 + _ -> 2 +main :: Int +main = intAdd (read { z: true, value: 42 }) (read { value: 1 }) +"#; + let Some(output) = run_with_wasmtime(source) else { + return; + }; + assert_eq!(output.status.code(), Some(44), "{output:?}"); +} + +#[test] +fn one_recovered_erased_record_serves_two_use_types() { + // A value recovered from an erased ADT slot keeps one authoritative label + // store and is reused at two different open-row use types without a + // second pack. + let source = r#"module Main where +import Partial.Unsafe (unsafePartial) +data Maybe a = Nothing | Just a +fromJust :: forall a. Partial => Maybe a -> a +fromJust (Just x) = x +read :: forall r. { value :: Int | r } -> Int +read record = case record of { value: v } -> v +reader :: forall s. { kept :: Boolean | s } -> Boolean +reader record = case record of { kept: k } -> k +continue :: { kept :: Boolean, value :: Int } -> Int +continue recovered = if reader recovered + then read { kept: 0, value: recovered.value } + else 1 +main :: Int +main = unsafePartial (continue (fromJust (Just { kept: true, value: 20 }))) +"#; + let Some(output) = run_with_wasmtime(source) else { + return; + }; + assert_eq!(output.status.code(), Some(20), "{output:?}"); +} diff --git a/crates/psrs-driver/src/tests/records/open_rows/mod.rs b/crates/psrs-driver/src/tests/records/open_rows/mod.rs new file mode 100644 index 00000000..ed3834b6 --- /dev/null +++ b/crates/psrs-driver/src/tests/records/open_rows/mod.rs @@ -0,0 +1,206 @@ +use super::*; + +#[test] +fn a_generic_record_accessor_uses_labels_across_distinct_closed_rows() { + let source = r#"module Main where +readValue :: forall r. { value :: Int | r } -> Int +readValue record = record.value +main :: Int +main = if intEq (readValue { a: false, value: 19 }) 19 + then if intEq (readValue { value: 23, z: "λ😀" }) 23 then 42 else 1 + else 2 +"#; + let Some(output) = run_with_wasmtime(source) else { + return; + }; + assert_eq!(output.status.code(), Some(42), "{output:?}"); +} + +#[test] +fn a_recursive_open_row_function_keeps_the_complete_record() { + let source = r#"module Main where +readValue :: forall r. Int -> { value :: Int | r } -> Int +readValue count record = if intEq count 0 then record.value + else readValue (intSub count 1) record +main :: Int +main = readValue 5 { a: "λ😀", value: 42, z: false } +"#; + let Some(output) = run_with_wasmtime(source) else { + return; + }; + assert_eq!(output.status.code(), Some(42), "{output:?}"); +} + +#[test] +fn an_open_row_result_recovers_the_callers_complete_closed_product() { + let source = r#"module Main where +keep :: forall r. { value :: Int | r } -> { value :: Int | r } +keep record = record +main :: Int +main = let result = keep { a: 19, value: 42, z: "λ😀" } + in if intEq result.a 19 then result.value else 1 +"#; + let Some(output) = run_with_wasmtime(source) else { + return; + }; + assert_eq!(output.status.code(), Some(42), "{output:?}"); +} + +#[test] +fn an_adt_erased_field_retains_a_records_open_row_storage() { + let source = r#"module Main where +data Box a = Box a +readBox :: forall r. Box { value :: Int | r } -> Int +readBox (Box record) = record.value +main :: Int +main = readBox (Box { a: "λ😀", value: 42 }) +"#; + let Some(output) = run_with_wasmtime(source) else { + return; + }; + assert_eq!(output.status.code(), Some(42), "{output:?}"); +} + +#[test] +fn an_array_erased_slot_retains_a_records_open_row_storage() { + let source = r#"module Main where +readArray :: forall r. Array { value :: Int | r } -> Int +readArray values = (arrayIndex values 0).value +main :: Int +main = readArray [{ a: "λ😀", value: 42 }] +"#; + let Some(output) = run_with_wasmtime(source) else { + return; + }; + assert_eq!(output.status.code(), Some(42), "{output:?}"); +} + +#[test] +fn nested_open_rows_keep_independent_tails() { + let source = r#"module Main where +readNested :: forall inner outer. { nested :: { value :: Int | inner } | outer } -> Int +readNested record = record.nested.value +main :: Int +main = readNested { a: false, nested: { a: "λ😀", value: 42 } } +"#; + let Some(output) = run_with_wasmtime(source) else { + return; + }; + assert_eq!(output.status.code(), Some(42), "{output:?}"); +} + +#[test] +fn a_callable_field_recovers_through_the_record_payload_owner() { + let source = r#"module Main where +applyRecord :: forall r. { callback :: Int -> Int | r } -> Int +applyRecord record = record.callback 42 +main :: Int +main = applyRecord { a: "λ😀", callback: \value -> value } +"#; + let Some(output) = run_with_wasmtime(source) else { + return; + }; + assert_eq!(output.status.code(), Some(42), "{output:?}"); +} + +#[test] +fn a_callable_result_converts_its_closed_record_into_open_storage() { + let source = r#"module Main where +consume :: forall r. (Int -> { value :: Int | r }) -> Int +consume make = (make 0).value +produce :: Int -> { label :: String, value :: Int } +produce _ = { label: "λ😀", value: 42 } +main :: Int +main = consume produce +"#; + let Some(output) = run_with_wasmtime(source) else { + return; + }; + assert_eq!(output.status.code(), Some(42), "{output:?}"); +} + +#[test] +fn a_record_pattern_selects_labels_across_distinct_open_rows() { + let source = r#"module Main where +read :: forall r. { value :: Int | r } -> Int +read record = case record of + { value: result } -> result +flag :: forall r. { other :: Int, flag :: Boolean | r } -> Boolean +flag record = case record of + { flag: result } -> result +main :: Int +main = if intEq (read { z: "λ😀", value: 40 }) 40 + then if flag { other: 9, flag: true, a: false } then 42 else 1 + else 2 +"#; + let Some(output) = run_with_wasmtime(source) else { + return; + }; + assert_eq!(output.status.code(), Some(42), "{output:?}"); +} + +#[test] +fn nested_record_patterns_select_open_row_values() { + let source = r#"module Main where +readNested :: forall inner outer. { nested :: { value :: Int | inner } | outer } -> Int +readNested record = case record of + { nested: { value } } -> value +main :: Int +main = readNested { a: false, nested: { z: "λ😀", value: 42 } } +"#; + let Some(output) = run_with_wasmtime(source) else { + return; + }; + assert_eq!(output.status.code(), Some(42), "{output:?}"); +} + +#[test] +fn open_row_updates_replace_labels_in_source_order_and_keep_the_tail() { + let source = r#"module Main where +swap :: forall r. { foo :: Int, keep :: Int, tail :: Int | r } -> { foo :: Int, keep :: Int, tail :: Int | r } +swap record = record { foo = 42, keep = 7 } +main :: Int +main = let result = swap { foo: 0, keep: 1, tail: 23, other: "λ😀" } + in if intEq result.foo 42 + then if booleanAnd (intEq result.keep 7) (intEq result.tail 23) then 42 else 1 + else 2 +"#; + let Some(output) = run_with_wasmtime(source) else { + return; + }; + assert_eq!(output.status.code(), Some(42), "{output:?}"); +} + +#[test] +fn a_type_changing_open_row_update_keeps_a_string_in_its_unknown_tail() { + let source = r#"module Main where +swap :: forall a r. { foo :: a | r } -> { foo :: Int | r } +swap record = record { foo = 42 } +main :: Int +main = let result = swap { foo: "λ😀", tail: 23 } + in if intEq result.foo 42 then if intEq result.tail 23 then 42 else 1 else 2 +"#; + let Some(output) = run_with_wasmtime(source) else { + return; + }; + assert_eq!(output.status.code(), Some(42), "{output:?}"); +} + +#[test] +fn a_case_branch_updates_its_open_row_scrutinee() { + let source = r#"module Main where +step :: forall r. { tag :: Int, always :: Int | r } -> Int +step record = case record of + { tag: 0 } -> (record { tag = 1 }).always + _ -> 0 +main :: Int +main = step { tag: 0, extra: 9, always: 42 } +"#; + let Some(output) = run_with_wasmtime(source) else { + return; + }; + assert_eq!(output.status.code(), Some(42), "{output:?}"); +} + +mod composition; +mod interactions; diff --git a/crates/psrs-driver/src/tests/wasi/filesystem.rs b/crates/psrs-driver/src/tests/wasi/filesystem.rs index 19da008f..44d6e43b 100644 --- a/crates/psrs-driver/src/tests/wasi/filesystem.rs +++ b/crates/psrs-driver/src/tests/wasi/filesystem.rs @@ -155,3 +155,78 @@ main = assert_eq!(output.stdout, b"stat-ok\n", "{output:?}"); let _ = std::fs::remove_dir_all(&directory); } + +#[test] +fn nested_record_timestamps_roundtrip_through_adt_slots_open_rows_and_the_canonical_abi() { + let source = r#"module Main where +import Effect.Unsafe (unsafePerformEffect) +import Prelude +import Data.Either (Either(..)) +import Data.Maybe (Maybe(..)) +import WASI.FileSystem + +data Box a = Box a +keep :: forall a. a -> Box (Array a) +keep value = Box [value] +resize :: forall r. { size :: Int | r } -> { size :: Int | r } +resize record = record { size = 42 } +seconds :: forall r. { seconds :: Int | r } -> Int +seconds record = record.seconds +nanos :: forall r. { nanoseconds :: Int | r } -> Int +nanos record = record.nanoseconds +main :: Int +main = + let directory = (arrayIndex (unsafePerformEffect preopens) 0)._1 + access = Timestamp { seconds: 1600000041, nanoseconds: 220000000 } + modified = Timestamp { seconds: 1600000042, nanoseconds: 330000000 } + changed = unsafePerformEffect (setTimesAt directory { symlinkFollow: true } "probe.bin" access modified) + in case changed of + Left _ -> 1 + Right _ -> case unsafePerformEffect (statAt directory { symlinkFollow: true } "probe.bin") of + Left _ -> 2 + Right original -> case keep (resize original) of + Box values -> + let copied = arrayIndex values 0 + in case copied.dataAccessTimestamp, copied.dataModificationTimestamp of + Just atime, Just mtime -> + if intEq original.size 6 && intEq copied.size 42 + && intEq (seconds atime) 1600000041 && intEq (nanos atime) 220000000 + && intEq (seconds mtime) 1600000042 && intEq (nanos mtime) 330000000 + then case unsafePerformEffect (setTimesAt directory { symlinkFollow: true } "probe.bin" (Timestamp atime) (Timestamp mtime)) of + Left _ -> 3 + Right _ -> case unsafePerformEffect (statAt directory { symlinkFollow: true } "probe.bin") of + Right { dataModificationTimestamp: Just returned, size: 6 } -> + if intEq returned.seconds 1600000042 && intEq returned.nanoseconds 330000000 then 42 else 4 + _ -> 5 + else 6 + _, _ -> 7 +"#; + let directory = std::env::temp_dir().join(format!( + "psrs-record-abi-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&directory).unwrap(); + let file = directory.join("probe.bin"); + std::fs::write(&file, b"abcdef").unwrap(); + let Some(output) = run_wasmtime_with_dirs(source, &[], None, &[(directory.clone(), "/data")]) + else { + std::fs::remove_dir_all(directory).unwrap(); + return; + }; + assert_eq!(output.status.code(), Some(42), "{output:?}"); + assert!(output.stderr.is_empty(), "{output:?}"); + assert!(output.stdout.is_empty(), "{output:?}"); + let modified = std::fs::metadata(file) + .unwrap() + .modified() + .unwrap() + .duration_since(std::time::UNIX_EPOCH) + .unwrap(); + assert_eq!(modified.as_secs(), 1600000042); + assert_eq!(modified.subsec_nanos(), 330000000); + std::fs::remove_dir_all(directory).unwrap(); +} diff --git a/crates/psrs-driver/tests/fixtures/upstream-prelude/Symbol.purs b/crates/psrs-driver/tests/fixtures/upstream-prelude/Symbol.purs new file mode 100644 index 00000000..80f289e3 --- /dev/null +++ b/crates/psrs-driver/tests/fixtures/upstream-prelude/Symbol.purs @@ -0,0 +1,24 @@ +module Data.Symbol + ( class IsSymbol + , reflectSymbol + , reifySymbol + ) where + +import Type.Proxy (Proxy(..)) + +-- | A class for known symbols +class IsSymbol (sym :: Symbol) where + reflectSymbol :: Proxy sym -> String + +-- local definition for use in `reifySymbol` +foreign import unsafeCoerce :: forall a b. a -> b + +reifySymbol :: forall r. String -> (forall sym. IsSymbol sym => Proxy sym -> r) -> r +reifySymbol s f = coerce f { reflectSymbol: \_ -> s } Proxy + where + coerce + :: (forall sym1. IsSymbol sym1 => Proxy sym1 -> r) + -> { reflectSymbol :: Proxy "" -> String } + -> Proxy "" + -> r + coerce = unsafeCoerce diff --git a/crates/psrs-driver/tests/rank_n.rs b/crates/psrs-driver/tests/rank_n.rs index 1a80b2e6..606a17d0 100644 --- a/crates/psrs-driver/tests/rank_n.rs +++ b/crates/psrs-driver/tests/rank_n.rs @@ -3,6 +3,26 @@ use std::process::Command; #[path = "support/rank_n/mod.rs"] mod cases; +#[test] +fn a_known_application_result_rejects_rigid_mismatch_before_escape() { + for source in [cases::RIGID_RESULT, cases::RIGID_NOMINAL_RESULT] { + let errors = psrs_driver::check_source("Main.purs", source) + .expect_err("a universally quantified callback cannot return its argument as Int"); + assert!( + errors + .iter() + .any(|error| error.code == Some("TypesDoNotUnify")), + "{errors:?}" + ); + assert!( + !errors + .iter() + .any(|error| error.code == Some("EscapedSkolem")), + "{errors:?}" + ); + } +} + #[test] fn source_rank_n_acceptance_and_rejection() { let mut failures = Vec::new(); diff --git a/crates/psrs-driver/tests/suite/corpus/mod.rs b/crates/psrs-driver/tests/suite/corpus/mod.rs index ea883332..4c2b67b9 100644 --- a/crates/psrs-driver/tests/suite/corpus/mod.rs +++ b/crates/psrs-driver/tests/suite/corpus/mod.rs @@ -3,6 +3,9 @@ use std::collections::{BTreeMap, HashSet}; use std::path::{Path, PathBuf}; +mod programs; +pub use programs::{collected_programs, program_is_ffi_excluded}; + /// The vendored corpus, or `$PURESCRIPT_REPO/tests/purs` when it is set. pub fn corpus_root() -> Option { if let Ok(path) = std::env::var("PURESCRIPT_REPO") { diff --git a/crates/psrs-driver/tests/suite/corpus/programs.rs b/crates/psrs-driver/tests/suite/corpus/programs.rs new file mode 100644 index 00000000..ebc9001c --- /dev/null +++ b/crates/psrs-driver/tests/suite/corpus/programs.rs @@ -0,0 +1,34 @@ +//! Runtime units follow upstream TestUtils.getTestFiles: a category-root file +//! and its same-stem support directory form one program. Frontend scoreboards +//! deliberately keep their separate per-file discovery contract. + +use super::{Case, collected_files, is_ffi_excluded}; +use std::path::{Path, PathBuf}; + +/// Select program roots before applying a diagnostic filter and limit. Never +/// infer test membership from successful parsing or the presence of `main`: +/// malformed roots and missing entries must still fail the runtime board. +pub fn collected_programs( + directory: &Path, + filter: Option<&str>, + limit: Option, +) -> Vec { + let mut roots = collected_files(directory, None) + .into_iter() + .filter(|path| path.parent() == Some(directory)) + .filter(|path| filter.is_none_or(|filter| path.to_string_lossy().contains(filter))) + .collect::>(); + if let Some(limit) = limit { + roots.truncate(limit); + } + roots +} + +/// A source FFI dependency excludes the whole program, including when the +/// declaration lives in a support module rather than the entry source. +pub fn program_is_ffi_excluded(case: &Case) -> bool { + case.own + .iter() + .chain(&case.loaded) + .any(|(path, text)| is_ffi_excluded(Path::new(path), text)) +} diff --git a/crates/psrs-driver/tests/suite/corpus/tests.rs b/crates/psrs-driver/tests/suite/corpus/tests.rs index 068e2055..95114774 100644 --- a/crates/psrs-driver/tests/suite/corpus/tests.rs +++ b/crates/psrs-driver/tests/suite/corpus/tests.rs @@ -204,3 +204,61 @@ fn a_standard_library_diagnostic_is_never_the_cases_own() { assert!(case.is_own(Origin::Source(0))); assert!(!case.is_own(Origin::Source(1))); } + +#[test] +fn runtime_programs_keep_support_sources_without_running_them_as_commands() { + let root = temp_directory("runtime-programs"); + let main = write( + &root, + "2018.purs", + "module Main where\nimport A\nmain :: Int\nmain = answer\n", + ); + let support = write( + &root, + "2018/A.purs", + "module A where\nanswer :: Int\nanswer = 42\n", + ); + let missing_entry = write(&root, "Missing.purs", "module Missing where\nanswer = 0\n"); + let malformed = write(&root, "Malformed.purs", "not valid PureScript"); + assert_eq!( + collected_programs(&root, None, None), + [main.clone(), malformed, missing_entry], + "all roots are measured, even when malformed or missing main" + ); + assert_eq!(collected_files(&root, None).len(), 4); + assert_eq!( + collected_programs(&root, Some("2018.purs"), Some(1)), + std::slice::from_ref(&main) + ); + let case = load_case(&main, &root, &std::fs::read_to_string(&main).unwrap()); + assert_eq!(case.own.len(), 2); + assert_eq!(case.own[1].0, support.to_string_lossy()); + let artifact = psrs_driver::compile_program_sources(&case.inputs()) + .expect("the support value must be resolved through its parent program"); + assert!(!artifact.wasm.is_empty()); + let _ = std::fs::remove_dir_all(root); +} + +#[test] +fn runtime_program_filter_precedes_the_limit() { + let root = temp_directory("runtime-filter"); + write(&root, "A.purs", "module Main where\nmain = 0\n"); + let last = write(&root, "Z.purs", "module Main where\nmain = 42\n"); + assert_eq!(collected_programs(&root, Some("Z.purs"), Some(1)), [last]); + let _ = std::fs::remove_dir_all(root); +} + +#[test] +fn runtime_ffi_exclusion_covers_support_modules_and_foreign_files() { + let root = temp_directory("runtime-ffi"); + let main = write(&root, "Case.purs", "module Main where\nmain = 0\n"); + let support = write(&root, "Case/A.purs", "module A where\nanswer = 42\n"); + let load = || load_case(&main, &root, &std::fs::read_to_string(&main).unwrap()); + assert!(!program_is_ffi_excluded(&load())); + std::fs::write(&support, "module A where\nforeign import answer :: Int\n").unwrap(); + assert!(program_is_ffi_excluded(&load())); + std::fs::write(&support, "module A where\nanswer = 42\n").unwrap(); + std::fs::write(support.with_extension("js"), "export const answer = 42;\n").unwrap(); + assert!(program_is_ffi_excluded(&load())); + let _ = std::fs::remove_dir_all(root); +} diff --git a/crates/psrs-driver/tests/suite/runtime.rs b/crates/psrs-driver/tests/suite/runtime.rs index 6de86df6..bd103d67 100644 --- a/crates/psrs-driver/tests/suite/runtime.rs +++ b/crates/psrs-driver/tests/suite/runtime.rs @@ -3,9 +3,10 @@ //! # What agreement means here //! //! The corpus has no execution goldens: `tests/upstream` vendors `.purs` sources -//! only, and upstream's own `passing` suite is a compile-time suite, so its -//! ground truth is "this module type checks". Our criterion is therefore -//! stronger and narrower at once: a `passing` file **agrees** when the pipeline +//! only. Upstream groups a root source and its same-stem support directory, +//! then compiles and runs that program, requiring its output to end in `Done`. +//! We use the same program units, but record output without that comparison: +//! a non-FFI program **agrees** when the pipeline //! compiles it, the generated component passes Wasm validation, and the guest //! runs to completion under Wasmtime without trapping. Nothing in the corpus //! requires argv, stdin, or a preopened directory, so the runner passes none; @@ -18,13 +19,16 @@ //! guest as a trap, which this board counts as a failure, and that is the only //! execution signal the corpus can express without goldens. //! -//! Every rejection is reported per file with its first blocking stage and +//! Support modules remain inputs of their parent program; they are never +//! separately compiled as commands. Frontend boards still measure source files. +//! +//! Every rejection is reported per program with its first blocking stage and //! diagnostic, and the categories a later phase recovers are counted apart: a //! missing library module is Phase 3's to land, a harness-loading failure is the //! harness's, and any other stage is Phase 2's or the backend's. use super::corpus::{ - Blockers, collected_files, corpus_root, is_ffi_excluded, load_case, print_histogram, + Blockers, collected_programs, corpus_root, load_case, print_histogram, program_is_ffi_excluded, suite_filter, suite_limit, unusable_siblings, }; use std::collections::BTreeMap; @@ -83,7 +87,7 @@ fn first_line(text: &str) -> &str { text.lines().next().unwrap_or("").trim() } -/// Compiles, validates, and runs every non-FFI `passing` file. +/// Compiles, validates, and runs every non-FFI `passing` program. /// /// Compilation includes Wasm validation: the backend rejects a component that /// does not validate before returning it, so a compiled artifact has already @@ -103,6 +107,7 @@ fn l6_runtime_scoreboard() { let limit = suite_limit(); let filter = suite_filter(); + let progress = std::env::var("PSRS_SUITE_PROGRESS").as_deref() == Ok("1"); let mut considered = 0usize; let mut excluded = 0usize; let mut agree = 0usize; @@ -114,7 +119,7 @@ fn l6_runtime_scoreboard() { let mut stale_siblings: Vec<(String, Vec)> = Vec::new(); let passing_dir = repo.join("passing"); - for path in collected_files(&passing_dir, limit) { + for path in collected_programs(&passing_dir, filter.as_deref(), limit) { let Ok(text) = std::fs::read_to_string(&path) else { continue; }; @@ -123,17 +128,15 @@ fn l6_runtime_scoreboard() { .unwrap_or(&path) .to_string_lossy() .into_owned(); - if let Some(filter) = filter.as_deref() - && !relative_path.contains(filter) - { - continue; + if progress { + println!("runtime program: {relative_path}"); } - if is_ffi_excluded(&path, &text) { + let case = load_case(&path, &passing_dir, &text); + if program_is_ffi_excluded(&case) { excluded += 1; continue; } considered += 1; - let case = load_case(&path, &passing_dir, &text); let outcome = match psrs_driver::compile_program_sources_with_prelude(&case.inputs()) { Err(errors) => { blockers.record(&case, &errors); diff --git a/crates/psrs-driver/tests/support/rank_n/contextual.rs b/crates/psrs-driver/tests/support/rank_n/contextual.rs new file mode 100644 index 00000000..04972597 --- /dev/null +++ b/crates/psrs-driver/tests/support/rank_n/contextual.rs @@ -0,0 +1,41 @@ +pub const RIGID_RESULT: &str = r#"module Main where +use :: forall r. (forall a. a -> r) -> r +use f = f 0 +bad :: Int +bad = use (\value -> value) +main :: Int +main = 42 +"#; + +pub const RIGID_NOMINAL_RESULT: &str = r#"module Main where +data Region :: Type -> Type -> Type +data Region r a = Region a +run :: forall a. (forall r. Region r a) -> a +run (Region value) = value +leak :: forall r. Region r (Region r Int) +leak = Region (Region 0) +main :: Int +main = run leak +"#; + +pub const CONSTANT_RESULT: &str = r#"module Main where +use :: forall r. (forall a. a -> r) -> r +use f = f 0 +result :: Int +result = use (\_ -> 42) +main :: Int +main = result +"#; + +pub const RETURNED_EVIDENCE: &str = r#"module Main where +class Mark a where + mark :: a -> Int +instance markInt :: Mark Int where + mark value = value +make :: Int -> (Mark Int => Int) +make _ = mark 42 +result :: Int +result = make 0 +main :: Int +main = result +"#; diff --git a/crates/psrs-driver/tests/support/rank_n/mod.rs b/crates/psrs-driver/tests/support/rank_n/mod.rs index 7bb4a96c..877e7e35 100644 --- a/crates/psrs-driver/tests/support/rank_n/mod.rs +++ b/crates/psrs-driver/tests/support/rank_n/mod.rs @@ -1,6 +1,8 @@ //! Source cases shared by semantic, differential, and execution acceptance. +mod contextual; mod reify; +pub use contextual::{RIGID_NOMINAL_RESULT, RIGID_RESULT}; pub const CHECK_ONLY: &[(&str, &str)] = &[( "annotated_polymorphic_array", @@ -34,6 +36,11 @@ main = case Holder (make 0) of ]; pub const ACCEPT: &[(&str, &str)] = &[ + ("contextual_constant_result", contextual::CONSTANT_RESULT), + ( + "contextual_returned_evidence", + contextual::RETURNED_EVIDENCE, + ), ("rank_n_separate_equations", reify::EQUATIONS), ("rank_n_multiple_scrutinees", reify::MULTIPLE), ("rank_n_guarded_equations", reify::GUARDED), diff --git a/crates/psrs-driver/tests/support/rank_n/reject.rs b/crates/psrs-driver/tests/support/rank_n/reject.rs index a7a91851..631e02b0 100644 --- a/crates/psrs-driver/tests/support/rank_n/reject.rs +++ b/crates/psrs-driver/tests/support/rank_n/reject.rs @@ -1,4 +1,5 @@ pub const REJECT: &[(&str, &str)] = &[ + ("contextual_rigid_result", super::RIGID_RESULT), ("rank_n_record_shares_instantiation", super::reify::RECORD), ( "ambiguous_unresolved_main_use", diff --git a/crates/psrs-driver/tests/upstream/rank_n.rs b/crates/psrs-driver/tests/upstream/rank_n.rs index 8f246722..8953481b 100644 --- a/crates/psrs-driver/tests/upstream/rank_n.rs +++ b/crates/psrs-driver/tests/upstream/rank_n.rs @@ -3,6 +3,39 @@ use super::*; #[path = "../support/rank_n/mod.rs"] mod cases; +#[test] +fn differential_contextual_application_result_diagnostic() { + if !purs_available() { + eprintln!("skipping: purs is not installed"); + return; + } + for (name, source) in [ + ("contextual-rigid-result", cases::RIGID_RESULT), + ("contextual-nominal-result", cases::RIGID_NOMINAL_RESULT), + ] { + let official = purs_sources_output(name, &[("Main.purs", source)]); + assert!(!official.status.success(), "{official:?}"); + assert!( + purs_error_codes(&official).contains(&"TypesDoNotUnify".to_owned()), + "{official:?}" + ); + let errors = psrs_driver::check_source("Main.purs", source) + .expect_err("the quantified argument cannot satisfy the concrete result"); + assert!( + errors + .iter() + .any(|error| error.code == Some("TypesDoNotUnify")), + "{errors:?}" + ); + assert!( + !errors + .iter() + .any(|error| error.code == Some("EscapedSkolem")), + "{errors:?}" + ); + } +} + #[test] fn differential_rank_n_rules_against_purs() { if !purs_available() { diff --git a/crates/psrs-driver/tests/upstream/symbol_reflection.rs b/crates/psrs-driver/tests/upstream/symbol_reflection.rs index e1cad71a..e2d53b9e 100644 --- a/crates/psrs-driver/tests/upstream/symbol_reflection.rs +++ b/crates/psrs-driver/tests/upstream/symbol_reflection.rs @@ -11,7 +11,9 @@ fn differential_symbol_reflection_against_purs() { .root .join("lib"); let proxy = std::fs::read_to_string(root.join("Type/Proxy.purs")).unwrap(); - let symbol = std::fs::read_to_string(root.join("Data/Symbol.purs")).unwrap(); + // Compare the pinned official source, independently of target foreign-slot + // adaptations in the installed package and their additional imports. + let symbol = include_str!("../fixtures/upstream-prelude/Symbol.purs"); let main = r#"module Main where import Data.Symbol as S import Type.Proxy (Proxy(..)) @@ -22,7 +24,7 @@ main = reflect (Proxy :: Proxy "λ😀") "#; let sources = [ ("Proxy.purs", proxy.as_str()), - ("Symbol.purs", symbol.as_str()), + ("Symbol.purs", symbol), ("Main.purs", main), ]; // Official FFI implementation: purescript-prelude v6.0.1, f4cad0ae8106185c9ab407f43cf9abf05c256af4. diff --git a/crates/psrs-resolve/src/resolver/module_resolution.rs b/crates/psrs-resolve/src/resolver/module_resolution.rs index da59a743..b1993f25 100644 --- a/crates/psrs-resolve/src/resolver/module_resolution.rs +++ b/crates/psrs-resolve/src/resolver/module_resolution.rs @@ -130,7 +130,9 @@ pub(crate) fn resolve_ast_module( continue; }; ExternalKind::Primitive(intrinsic) - } else if interface.starts_with("psrs:runtime-") { + } else if interface.starts_with("psrs:runtime-") + || interface == "psrs:record-storage" + { ExternalKind::Runtime { module: interface.into(), function: function.into(), diff --git a/crates/psrs-runtime/src/abi/mod.rs b/crates/psrs-runtime/src/abi/mod.rs index 3fe6bd1f..4c07f83b 100644 --- a/crates/psrs-runtime/src/abi/mod.rs +++ b/crates/psrs-runtime/src/abi/mod.rs @@ -3,10 +3,12 @@ mod allocator; mod number; +mod record; mod storage; pub use allocator::*; pub use number::*; +pub use record::*; pub use storage::*; /// The runtime imports the application's canonical memory. diff --git a/crates/psrs-runtime/src/abi/record.rs b/crates/psrs-runtime/src/abi/record.rs new file mode 100644 index 00000000..7d2f1cfa --- /dev/null +++ b/crates/psrs-runtime/src/abi/record.rs @@ -0,0 +1,106 @@ +//! Source contract for compiler-projected operations on the shared record store. + +pub const RECORD_STORAGE_MODULE: &str = "psrs:record-storage"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum RecordPrimitive { + Length, + KeyAt, + PayloadAt, + Build, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RecordSourceValue { + Record, + Int, + String, + Slot, + ArrayString, + ArraySlot, +} + +pub struct RecordSourceContract { + pub parameters: &'static [RecordSourceValue], + pub result: RecordSourceValue, + pub projection: RecordProjection, +} + +/// How source operands become an operation on the shared record owner. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RecordProjection { + Length { record: usize }, + KeyAt { index: usize, record: usize }, + PayloadAt { index: usize, record: usize }, + Build { labels: usize, values: usize }, +} + +impl RecordPrimitive { + pub const ALL: [Self; 4] = [Self::Length, Self::KeyAt, Self::PayloadAt, Self::Build]; + + pub fn from_export(export: &str) -> Option { + Self::ALL + .into_iter() + .find(|primitive| primitive.export() == export) + } + + pub const fn export(self) -> &'static str { + match self { + Self::Length => "length", + Self::KeyAt => "key_at", + Self::PayloadAt => "payload_at", + Self::Build => "build", + } + } + + pub const fn source_contract(self) -> RecordSourceContract { + use RecordSourceValue::*; + match self { + Self::Length => RecordSourceContract { + parameters: &[Record], + result: Int, + projection: RecordProjection::Length { record: 0 }, + }, + Self::KeyAt => RecordSourceContract { + parameters: &[Int, Record], + result: String, + projection: RecordProjection::KeyAt { + index: 0, + record: 1, + }, + }, + Self::PayloadAt => RecordSourceContract { + parameters: &[Int, Record], + result: Slot, + projection: RecordProjection::PayloadAt { + index: 0, + record: 1, + }, + }, + Self::Build => RecordSourceContract { + parameters: &[ArrayString, ArraySlot], + result: Record, + projection: RecordProjection::Build { + labels: 0, + values: 1, + }, + }, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exports_are_exact_and_complete() { + for primitive in RecordPrimitive::ALL { + assert_eq!( + RecordPrimitive::from_export(primitive.export()), + Some(primitive) + ); + } + assert_eq!(RecordPrimitive::from_export("update"), None); + } +} diff --git a/crates/psrs-thir/src/external_type_tests.rs b/crates/psrs-thir/src/external_type_tests.rs index 5a3a107a..5407728c 100644 --- a/crates/psrs-thir/src/external_type_tests.rs +++ b/crates/psrs-thir/src/external_type_tests.rs @@ -24,6 +24,7 @@ fn fixture() -> Module { }], types: vec![Type::Constructor(TypeConstructor::Int)], newtype_ids: Vec::new(), + ordinary_data_ids: Vec::new(), opaque_ids: Vec::new(), callable_types: Vec::new(), constructors: Vec::new(), diff --git a/crates/psrs-thir/src/lib.rs b/crates/psrs-thir/src/lib.rs index ab5add63..25b0b220 100644 --- a/crates/psrs-thir/src/lib.rs +++ b/crates/psrs-thir/src/lib.rs @@ -203,6 +203,10 @@ pub struct Module { /// checker keeps these types distinct; later lowering uses this metadata /// to pass their field value through without allocating a wrapper. pub newtype_ids: Vec, + /// Ordinary algebraic data declarations, including constructorless data. + /// Empty constructor lists alone cannot distinguish these from absent or + /// foreign declaration metadata. + pub ordinary_data_ids: Vec, /// Foreign data declarations. Their type node is still /// `Constructor(User(id))`; this set, with an empty constructor list, is /// what keeps the type opaque. It is not a runtime layout. diff --git a/crates/psrs-thir/src/rank_n_tests.rs b/crates/psrs-thir/src/rank_n_tests.rs index 0e717419..1c0ab06d 100644 --- a/crates/psrs-thir/src/rank_n_tests.rs +++ b/crates/psrs-thir/src/rank_n_tests.rs @@ -57,6 +57,7 @@ fn module(types: Vec, declarations: Vec) -> Module { external_types: Vec::new(), types, newtype_ids: Vec::new(), + ordinary_data_ids: Vec::new(), opaque_ids: Vec::new(), callable_types: Vec::new(), constructors: Vec::new(), diff --git a/crates/psrs-thir/src/tests/constructed_dictionary.rs b/crates/psrs-thir/src/tests/constructed_dictionary.rs index 04d564fd..acc25b15 100644 --- a/crates/psrs-thir/src/tests/constructed_dictionary.rs +++ b/crates/psrs-thir/src/tests/constructed_dictionary.rs @@ -26,6 +26,7 @@ fn a_runtime_dictionary_cannot_authorize_a_coercion() { Type::Application(TypeId(2), TypeId(1)), ], newtype_ids: Vec::new(), + ordinary_data_ids: Vec::new(), opaque_ids: Vec::new(), callable_types: Vec::new(), constructors: Vec::new(), diff --git a/crates/psrs-thir/src/tests/mod.rs b/crates/psrs-thir/src/tests/mod.rs index c81e25ed..9a03ed48 100644 --- a/crates/psrs-thir/src/tests/mod.rs +++ b/crates/psrs-thir/src/tests/mod.rs @@ -10,6 +10,7 @@ fn verifier_rejects_invalid_type_references() { external_types: Vec::new(), types: vec![Type::Constructor(TypeConstructor::Int)], newtype_ids: Vec::new(), + ordinary_data_ids: Vec::new(), opaque_ids: Vec::new(), callable_types: Vec::new(), constructors: Vec::new(), @@ -56,6 +57,7 @@ fn verifier_checks_instance_context_against_constructor_parameters() { Type::Application(TypeId(5), TypeId(3)), ], newtype_ids: Vec::new(), + ordinary_data_ids: Vec::new(), opaque_ids: Vec::new(), callable_types: Vec::new(), constructors: Vec::new(), @@ -157,6 +159,7 @@ fn verifier_requires_superclass_evidence_to_name_a_well_typed_field() { Type::Constructor(TypeConstructor::Boolean), ], newtype_ids: Vec::new(), + ordinary_data_ids: Vec::new(), opaque_ids: Vec::new(), callable_types: Vec::new(), constructors: Vec::new(), @@ -243,6 +246,7 @@ fn verifier_accepts_alpha_equivalent_superclass_field_types() { Type::Application(TypeId(17), TypeId(12)), ], newtype_ids: Vec::new(), + ordinary_data_ids: Vec::new(), opaque_ids: Vec::new(), callable_types: Vec::new(), constructors: Vec::new(), @@ -310,6 +314,7 @@ fn literal_reference_module(reference_type: TypeId) -> Module { Type::Application(TypeId(0), TypeId(1)), ], newtype_ids: Vec::new(), + ordinary_data_ids: Vec::new(), opaque_ids: Vec::new(), callable_types: Vec::new(), constructors: Vec::new(), @@ -396,6 +401,7 @@ fn verifier_rejects_coercion_evidence_for_a_different_boundary() { Type::Application(TypeId(3), TypeId(2)), ], newtype_ids: Vec::new(), + ordinary_data_ids: Vec::new(), opaque_ids: Vec::new(), callable_types: Vec::new(), constructors: Vec::new(), diff --git a/crates/psrs-thir/src/verify/mod.rs b/crates/psrs-thir/src/verify/mod.rs index c8c813ae..4eecbb8e 100644 --- a/crates/psrs-thir/src/verify/mod.rs +++ b/crates/psrs-thir/src/verify/mod.rs @@ -7,6 +7,7 @@ mod semantics; pub(super) fn verify_module(module: &Module) -> Result<(), Vec> { let mut errors = Vec::new(); + verify_type_kind_metadata(module, &mut errors); let mut external_type_symbols = std::collections::HashSet::new(); for external_type in &module.external_types { if !external_type_symbols.insert(external_type.symbol) { @@ -97,6 +98,37 @@ pub(super) fn verify_module(module: &Module) -> Result<(), Vec> { } } +fn verify_type_kind_metadata(module: &Module, errors: &mut Vec) { + let newtypes = module + .newtype_ids + .iter() + .copied() + .collect::>(); + let ordinary = module + .ordinary_data_ids + .iter() + .copied() + .collect::>(); + let opaque = module + .opaque_ids + .iter() + .copied() + .collect::>(); + if newtypes.len() != module.newtype_ids.len() + || ordinary.len() != module.ordinary_data_ids.len() + || opaque.len() != module.opaque_ids.len() + || newtypes + .iter() + .any(|id| ordinary.contains(id) || opaque.contains(id)) + || ordinary.iter().any(|id| opaque.contains(id)) + { + errors.push(VerifyError { + span: module.span, + message: "type declaration kind metadata is duplicated or contradictory", + }); + } +} + fn verify_expr(expression: &Expr, module: &Module, errors: &mut Vec) { psrs_span::with_sufficient_stack(|| verify_expr_inner(expression, module, errors)) } @@ -381,3 +413,37 @@ fn verify_type_id(id: TypeId, type_count: usize, span: TextRange, errors: &mut V }); } } + +#[cfg(test)] +mod type_kind_tests { + use super::*; + use crate::ConstructorInfo; + use psrs_hir::{ModuleId, TypeId as HirTypeId}; + + #[test] + fn rejects_ordinary_data_that_is_also_declared_opaque() { + let id = HirTypeId::new(ModuleId(1), 2); + let module = Module { + id: ModuleId(1), + name: "Carrier".into(), + externals: Vec::new(), + external_types: Vec::new(), + types: Vec::new(), + newtype_ids: Vec::new(), + ordinary_data_ids: vec![id], + opaque_ids: vec![id], + callable_types: Vec::new(), + constructors: Vec::::new(), + declarations: Vec::new(), + type_names: vec![(id, "Carrier.Cell".into())], + span: TextRange::new(0, 1), + }; + let mut errors = Vec::new(); + verify_type_kind_metadata(&module, &mut errors); + assert!( + errors + .iter() + .any(|error| error.message.contains("duplicated or contradictory")) + ); + } +} diff --git a/crates/psrs-thir/src/verify/semantics/matching/tests.rs b/crates/psrs-thir/src/verify/semantics/matching/tests.rs index 201348e0..f971d7e1 100644 --- a/crates/psrs-thir/src/verify/semantics/matching/tests.rs +++ b/crates/psrs-thir/src/verify/semantics/matching/tests.rs @@ -44,6 +44,7 @@ fn module(types: Vec, declarations: Vec) -> Module { external_types: Vec::new(), types, newtype_ids: Vec::new(), + ordinary_data_ids: Vec::new(), opaque_ids: Vec::new(), callable_types: Vec::new(), constructors: Vec::new(), diff --git a/crates/psrs-typecheck/Cargo.toml b/crates/psrs-typecheck/Cargo.toml index 2497cdb2..d0221f6e 100644 --- a/crates/psrs-typecheck/Cargo.toml +++ b/crates/psrs-typecheck/Cargo.toml @@ -6,6 +6,7 @@ license.workspace = true [dependencies] psrs-hir.workspace = true +psrs-coverage.workspace = true psrs-kind.workspace = true psrs-span.workspace = true psrs-thir.workspace = true diff --git a/crates/psrs-typecheck/src/typecheck/classes/evidence/typing.rs b/crates/psrs-typecheck/src/typecheck/classes/evidence/typing.rs index da7ce507..19829a7f 100644 --- a/crates/psrs-typecheck/src/typecheck/classes/evidence/typing.rs +++ b/crates/psrs-typecheck/src/typecheck/classes/evidence/typing.rs @@ -252,6 +252,7 @@ impl Checker { report_span: self.scope.report_origin.unwrap_or(constraint.span), givens: self.scope.givens.clone(), solution: None, + witness: None, }); index } diff --git a/crates/psrs-typecheck/src/typecheck/classes/solve/builders.rs b/crates/psrs-typecheck/src/typecheck/classes/solve/builders.rs index 6a43e915..537bff4b 100644 --- a/crates/psrs-typecheck/src/typecheck/classes/solve/builders.rs +++ b/crates/psrs-typecheck/src/typecheck/classes/solve/builders.rs @@ -32,6 +32,7 @@ impl Checker { report_span: self.scope.report_origin.unwrap_or(span), givens: self.scope.givens.clone(), solution: Some(solution), + witness: None, } } @@ -55,6 +56,7 @@ impl Checker { report_span: self.scope.report_origin.unwrap_or(span), givens: self.scope.givens.clone(), solution: None, + witness: None, } } diff --git a/crates/psrs-typecheck/src/typecheck/classes/solve/entry.rs b/crates/psrs-typecheck/src/typecheck/classes/solve/entry.rs index 43da20a0..3f8f1455 100644 --- a/crates/psrs-typecheck/src/typecheck/classes/solve/entry.rs +++ b/crates/psrs-typecheck/src/typecheck/classes/solve/entry.rs @@ -124,13 +124,25 @@ impl Checker { if self.policy_keeps_unsolved(unsolved, &constraint) { retained.push(index); } else { - let rendered = - self.display_constraint(constraint.class_id, &constraint.arguments); - self.state.errors.push(TypeCheckError::new( - TypeCheckErrorKind::NoInstance, - constraint.span, - format!("no instance for constraint {rendered}"), - )); + if prim::is_report_only(constraint.class_id) { + self.state + .errors + .push(prim::reports::unresolved_report_error( + self, + constraint.class_id, + &constraint.arguments, + constraint.span, + constraint.witness.as_deref(), + )); + } else { + let rendered = + self.display_constraint(constraint.class_id, &constraint.arguments); + self.state.errors.push(TypeCheckError::new( + TypeCheckErrorKind::NoInstance, + constraint.span, + format!("no instance for constraint {rendered}"), + )); + } } } } diff --git a/crates/psrs-typecheck/src/typecheck/entry.rs b/crates/psrs-typecheck/src/typecheck/entry.rs index 9512f46e..cdc2eea8 100644 --- a/crates/psrs-typecheck/src/typecheck/entry.rs +++ b/crates/psrs-typecheck/src/typecheck/entry.rs @@ -230,6 +230,12 @@ pub fn typecheck_module_with_checked_kinds_and_module_names_and_warnings( .filter(|declaration| declaration.kind == hir::TypeDeclarationKind::Newtype) .map(|declaration| declaration.id) .collect(); + let ordinary_data_ids = module + .types + .iter() + .filter(|declaration| declaration.kind == hir::TypeDeclarationKind::Data) + .map(|declaration| declaration.id) + .collect(); let opaque_ids = module .types .iter() @@ -293,6 +299,7 @@ pub fn typecheck_module_with_checked_kinds_and_module_names_and_warnings( external_types, types: types.values, newtype_ids, + ordinary_data_ids, opaque_ids, callable_types, constructors, diff --git a/crates/psrs-typecheck/src/typecheck/infer/application.rs b/crates/psrs-typecheck/src/typecheck/infer/application.rs index 65a709b4..e2f6e3d7 100644 --- a/crates/psrs-typecheck/src/typecheck/infer/application.rs +++ b/crates/psrs-typecheck/src/typecheck/infer/application.rs @@ -7,6 +7,17 @@ impl Checker { argument: &hir::Expr, span: TextRange, ) -> Option<(InferredExprKind, InferType)> { + let application = self.infer_application_with_expected(function, argument, span, None)?; + Some((application.kind, application.ty)) + } + + pub(super) fn infer_application_with_expected( + &mut self, + function: &hir::Expr, + argument: &hir::Expr, + span: TextRange, + expected: Option, + ) -> Option { let function = self.infer_expr(function)?; let scheme = Scheme::monomorphic(function.ty.clone()); let function = self.instantiate_expression_use(function, &scheme, span); @@ -26,11 +37,34 @@ impl Checker { ); (parameter, result) }; - let argument = self.infer_expr_with_expected(argument, Some(parameter.clone()))?; - let result = self.resolve_type(result); - Some(( - InferredExprKind::Application(Box::new(function), Box::new(argument)), - result, - )) + // A contextual result constrains the callable before an argument opens + // a nested skolem scope. Otherwise a result already known to be Int, + // for example, is treated as an unconstrained outer unknown and a + // rigid mismatch is prematurely diagnosed as a skolem escape. + // Use the ordinary scheme operation, retaining result dictionaries + // until the actual application exists and can receive their evidence. + let result_use = expected.as_ref().map(|expected| { + let (constraints, body) = self.instantiate_use(&Scheme::monomorphic(result.clone())); + self.subsume(body.clone(), expected.clone(), span); + (constraints, body) + }); + let argument = self.infer_expr_with_expected(argument, Some(parameter))?; + let application = InferredExpr { + kind: InferredExprKind::Application(Box::new(function), Box::new(argument)), + ty: self.resolve_type(result), + span, + }; + match (expected, result_use) { + (Some(expected), Some((constraints, body))) => { + let application = InferredExpr { + ty: self.resolve_type(body), + ..application + }; + let mut application = self.apply_constraints(application, constraints, span); + application.ty = self.resolve_type(expected); + Some(application) + } + _ => Some(application), + } } } diff --git a/crates/psrs-typecheck/src/typecheck/infer/case.rs b/crates/psrs-typecheck/src/typecheck/infer/case.rs index 8c7d76e2..46989fd6 100644 --- a/crates/psrs-typecheck/src/typecheck/infer/case.rs +++ b/crates/psrs-typecheck/src/typecheck/infer/case.rs @@ -66,36 +66,56 @@ impl Checker { }); } let ty = result_ty.unwrap_or_else(|| self.fresh()); - // Report-only Partial has no runtime dictionary, but its checked - // authorization must survive erasure. Reuse the generated empty-case - // failure representation used by guard desugaring; the marked fallback - // contributes coverage and never hides redundant source alternatives. - if !branches.is_empty() - && self.scope.givens.iter().any(|(constraint, _)| { + // Coverage owns the case's exhaustiveness authorization. When the + // alternatives cannot cover the scrutinee, the analysis supplies the + // missing rows for a report-only `Partial` wanted; an in-scope + // `Partial` given discharges that wanted at this case's own solve + // point. Either way the case gains the generated empty-case failure + // representation guard desugaring uses, so the alternatives keep + // contributing coverage without hiding redundant source rows. + if !branches.is_empty() { + let analysis = self.case_coverage(&scrutinee.ty, &inferred); + if !analysis.exhaustive { + let constraint = self.build_constraint(hir::TypeId::PRIM_PARTIAL, Vec::new(), span); + let index = self.push_wanted( + ClassConstraint { + class_id: hir::TypeId::PRIM_PARTIAL, + arguments: Vec::new(), + span, + }, + constraint.dictionary_type, + ); + // One analysis yields the first uncovered row; its atoms are + // the case the official report prints first. + self.state.wanted[index].witness = + analysis.witness_atoms.clone().map(|atoms| vec![atoms]); + } + let given_discharges = self.scope.givens.iter().any(|(constraint, _)| { constraint.class_id == hir::TypeId::PRIM_PARTIAL && constraint.arguments.is_empty() - }) - { - inferred.push(InferredCaseBranch { - pattern: InferredPattern { - kind: InferredPatternKind::Wildcard, - ty: scrutinee.ty.clone(), - span, - }, - value: InferredExpr { - kind: InferredExprKind::Case { - scrutinee: Box::new(InferredExpr { - kind: InferredExprKind::Integer(0), - ty: InferType::Constructor(TypeConstructor::Int), - span, - }), - branches: Vec::new(), + }); + if !analysis.exhaustive || given_discharges { + inferred.push(InferredCaseBranch { + pattern: InferredPattern { + kind: InferredPatternKind::Wildcard, + ty: scrutinee.ty.clone(), + span, + }, + value: InferredExpr { + kind: InferredExprKind::Case { + scrutinee: Box::new(InferredExpr { + kind: InferredExprKind::Integer(0), + ty: InferType::Constructor(TypeConstructor::Int), + span, + }), + branches: Vec::new(), + }, + ty: ty.clone(), + span, }, - ty: ty.clone(), span, - }, - span, - coverage: hir::CaseBranchCoverage::PartialFallback, - }); + coverage: hir::CaseBranchCoverage::PartialFallback, + }); + } } Some(InferredExpr { kind: InferredExprKind::Case { diff --git a/crates/psrs-typecheck/src/typecheck/infer/coverage.rs b/crates/psrs-typecheck/src/typecheck/infer/coverage.rs new file mode 100644 index 00000000..a35d8a18 --- /dev/null +++ b/crates/psrs-typecheck/src/typecheck/infer/coverage.rs @@ -0,0 +1,329 @@ +//! The checked environment's direct projection onto the shared coverage +//! engine. +//! +//! The engine consumes a neutral pattern algebra and a [`Types`] fact table. +//! The checker's types are trees with inference variables, so one analysis +//! run first interns its types into skeleton identities — structural shapes +//! with unknowns keyed by their solver stamp — and then answers every engine +//! question from those skeletons plus the environment's constructor rows. +//! Constructor rows are elaborated with the same helper pattern checking +//! uses, on fresh throwaway unknowns, so coverage never unifies anything. +//! +//! One run visits only the scrutinee-rooted shape family, so the elaboration +//! cost stays proportional to the case's own types, not to the environment. + +use super::super::*; +use psrs_coverage::{Branch, ConstructorRow, Pattern as SharedPattern, Types as CoverageTypes}; + +/// The interned skeleton of one checked type. +#[derive(Clone, PartialEq, Eq, Hash)] +enum Skeleton { + Constructor(TypeConstructor), + Applied(u32, u32), + ForAll(u32), + Constrained(u32), + RowEmpty, + RowExtend { + label: String, + ty: u32, + tail: u32, + }, + TypeLevelString(String), + TypeLevelInt(i64), + /// A solver unknown. The stamp keys equality, so every mention of the + /// same inference variable shares one coverage identity and structurally + /// distinct unknowns never do. + Unknown(u32), +} + +/// The interned shape table one analysis consumes, implementing the shared +/// [`CoverageTypes`] facts over the supervisor's environment. +#[derive(Default)] +pub(super) struct CaseCoverage { + ids: HashMap, + skeletons: Vec, + rows: HashMap>>, + names: HashMap, +} + +impl Checker { + /// Runs the shared engine over one case's checked alternatives. + /// + /// The scrutinee is resolved first, so branch-time unification the + /// alternatives performed decides the shape the analysis queries. + pub(super) fn case_coverage( + &mut self, + scrutinee_type: &InferType, + branches: &[InferredCaseBranch], + ) -> psrs_coverage::Analysis { + let scrutinee = self.resolve_type(scrutinee_type.clone()); + let mut coverage = CaseCoverage::default(); + let scrutinee_id = coverage.intern(&scrutinee); + let mut shared = Vec::with_capacity(branches.len()); + for branch in branches { + // Built lazily, but engine queries on the alternatives' own types + // precede any row lookup, so intern the patterns first: a shape + // the engine reaches through a row field is interned by the row's + // own build below. + let pattern = coverage.shared_pattern(self, &branch.pattern); + shared.push(Branch { + coverage: branch.coverage, + pattern, + }); + } + // Elaborate every reachable user type's rows. Reachability grows while + // interning, so the loop runs to a fixpoint; a cycle in data types + // terminates because each iteration interns new rows and the next scan + // finds nothing unvisited. + loop { + let pending = coverage.unexamined_user_types(); + if pending.is_empty() { + break; + } + for type_id in pending { + let infos = self + .env + .constructor_info + .values() + .filter(|info| info.type_id == type_id) + .cloned() + .collect::>(); + let mut built = Vec::new(); + for info in infos { + let (_, fields) = self.instantiate_constructor(&info); + let field_ids = fields + .iter() + .map(|field| { + let resolved = self.resolve_type(field.clone()); + coverage.intern(&resolved) + }) + .collect(); + built.push(ConstructorRow { + symbol: info.symbol, + fields: field_ids, + }); + coverage.names.insert(info.symbol, info.name.clone()); + } + // An uninhabited or foreign type keeps no usable rows: the + // `constructors_of` answer stays unknown (an empty entry), + // matching the backend's own policy. The entry still records + // the type as examined so the fixpoint scan terminates. + coverage.rows.insert(type_id, built); + } + } + psrs_coverage::analyze(&coverage, scrutinee_id, &shared) + } +} + +impl CoverageTypes for CaseCoverage { + fn shape(&self, ty: u32) -> u32 { + let mut ty = ty; + loop { + match self.skeletons[ty as usize] { + Skeleton::ForAll(body) | Skeleton::Constrained(body) => ty = body, + _ => return ty, + } + } + } + + fn array_element(&self, ty: u32) -> Option { + let shaped = self.shape(ty); + let Skeleton::Applied(function, _) = self.skeletons[shaped as usize] else { + return None; + }; + let shaped_head = self.shape(function); + matches!( + self.skeletons[shaped_head as usize], + Skeleton::Constructor(TypeConstructor::Array) + ) + .then_some(self.shape_argument_of(shaped)) + } + + fn constructors_of(&self, ty: u32) -> Option>> { + let head = self.constructor_head(ty)?; + let Skeleton::Constructor(TypeConstructor::User(type_id)) = self.skeletons[head as usize] + else { + return None; + }; + self.rows + .get(&type_id) + .filter(|rows| !rows.is_empty()) + .cloned() + } + + fn record_fields(&self, ty: u32) -> Option> { + let shaped = self.shape(ty); + let Skeleton::Applied(function, argument) = self.skeletons[shaped as usize] else { + return None; + }; + let is_record = matches!( + self.skeletons[self.shape(function) as usize], + Skeleton::Constructor(TypeConstructor::Record) + ); + if !is_record { + return None; + } + let row = self.shape(argument); + let mut fields = Vec::new(); + let mut current = row; + loop { + match self.skeletons[current as usize].clone() { + Skeleton::RowExtend { label, ty, tail } => { + fields.push((label, ty)); + current = tail; + } + // A row variable tail means the record may gain fields, and a + // pattern reading the known labels still covers every value of + // the row: row unification keeps the listed fields and only + // grows beyond them. The backend's own closed-record answer is + // for core types whose rows are already solved. + _ => return Some(fields), + } + } + } + + fn boolean_type(&self, ty: u32) -> bool { + self.constructor_head(ty).is_some_and(|head| { + matches!( + self.skeletons[head as usize], + Skeleton::Constructor(TypeConstructor::Boolean) + ) + }) + } + + fn constructor_name(&self, symbol: SymbolId) -> String { + self.names + .get(&symbol) + .cloned() + .unwrap_or_else(|| format!("Constructor{}", symbol.index)) + } +} + +impl CaseCoverage { + fn intern(&mut self, ty: &InferType) -> u32 { + let key = match ty { + InferType::Constructor(constructor) => Skeleton::Constructor(*constructor), + InferType::Application(function, argument) => { + Skeleton::Applied(self.intern(function), self.intern(argument)) + } + InferType::ForAll { body, .. } => Skeleton::ForAll(self.intern(body)), + InferType::Constrained { body, .. } => Skeleton::Constrained(self.intern(body)), + InferType::RowEmpty => Skeleton::RowEmpty, + InferType::RowExtend { label, ty, tail } => Skeleton::RowExtend { + label: label.clone(), + ty: self.intern(ty), + tail: self.intern(tail), + }, + InferType::TypeLevelString(value) => Skeleton::TypeLevelString(value.clone()), + InferType::TypeLevelInt(value) => Skeleton::TypeLevelInt(*value), + InferType::Variable(variable) => Skeleton::Unknown(*variable), + }; + if let Some(&id) = self.ids.get(&key) { + return id; + } + let id = self.skeletons.len() as u32; + self.skeletons.push(key.clone()); + self.ids.insert(key, id); + id + } + + /// Shared spine-walk: strips quantified prefixes and, at an application, + /// follows the head, returning the id whose skeleton is the outer + /// constructor. `None` for rows and unknowns, which name no type head. + fn constructor_head(&self, ty: u32) -> Option { + let mut ty = self.shape(ty); + loop { + match self.skeletons[ty as usize] { + Skeleton::Constructor(_) => return Some(ty), + Skeleton::Applied(function, _) => ty = self.shape(function), + _ => return None, + } + } + } + + /// The argument identity of an application skeleton. + fn shape_argument_of(&self, ty: u32) -> u32 { + match &self.skeletons[ty as usize] { + Skeleton::Applied(_, argument) => *argument, + _ => ty, + } + } + + fn unexamined_user_types(&self) -> Vec { + let mut pending = Vec::new(); + for skeleton in &self.skeletons { + let Skeleton::Constructor(TypeConstructor::User(type_id)) = skeleton else { + continue; + }; + if !self.rows.contains_key(type_id) { + pending.push(*type_id); + } + } + pending + } + + fn shared_pattern( + &mut self, + checker: &Checker, + pattern: &InferredPattern, + ) -> SharedPattern { + let ty = self.intern(&checker.resolve_type(pattern.ty.clone())); + self.shared_pattern_kind(checker, &pattern.kind, ty) + } + + fn shared_pattern_kind( + &mut self, + checker: &Checker, + kind: &InferredPatternKind, + ty: u32, + ) -> SharedPattern { + match kind { + InferredPatternKind::Wildcard => SharedPattern::Any { ty }, + InferredPatternKind::Literal { literal } => SharedPattern::Literal { + literal: shared_literal(literal), + ty, + }, + InferredPatternKind::Var { .. } => { + // A variable binder covers every value the scrutinee allows, + // so the binder itself carries no coverage fact. + SharedPattern::Any { ty } + } + InferredPatternKind::Named { pattern, .. } => { + // An as-pattern's inner shape is the fact. + self.shared_pattern(checker, pattern) + } + InferredPatternKind::Array { elements } => SharedPattern::Array { + elements: elements + .iter() + .map(|element| self.shared_pattern(checker, element)) + .collect(), + ty, + }, + InferredPatternKind::Constructor { symbol, arguments } => SharedPattern::Constructor { + symbol: *symbol, + arguments: arguments + .iter() + .map(|argument| self.shared_pattern(checker, argument)) + .collect(), + ty, + }, + InferredPatternKind::Record { fields } => SharedPattern::Record { + fields: fields + .iter() + .map(|(label, value)| (label.clone(), self.shared_pattern(checker, value))) + .collect(), + ty, + }, + } + } +} + +fn shared_literal(literal: &thir::PatternLiteral) -> psrs_coverage::Literal { + match literal { + thir::PatternLiteral::Integer(value) => psrs_coverage::Literal::Integer(*value), + thir::PatternLiteral::Number(value) => psrs_coverage::Literal::Number(value.clone()), + thir::PatternLiteral::String(value) => psrs_coverage::Literal::String(value.clone()), + thir::PatternLiteral::Char(value) => psrs_coverage::Literal::Char(*value), + thir::PatternLiteral::Boolean(value) => psrs_coverage::Literal::Boolean(*value), + } +} diff --git a/crates/psrs-typecheck/src/typecheck/infer/expected.rs b/crates/psrs-typecheck/src/typecheck/infer/expected.rs index cc0f3c11..35de8f0d 100644 --- a/crates/psrs-typecheck/src/typecheck/infer/expected.rs +++ b/crates/psrs-typecheck/src/typecheck/infer/expected.rs @@ -245,6 +245,20 @@ impl Checker { ); } + // Only monomorphic contexts guide application inference. Propagating + // a nested forall here would infer impredicative constructor arguments + // before their values have been checked. + if let hir::ExprKind::Application(function, argument) = &expression.kind + && self.resolve_type(expected.clone()).is_monotype() + { + return self.infer_application_with_expected( + function, + argument, + expression.span, + Some(expected), + ); + } + let inferred = self.infer_expr(expression)?; // A bare class-method use keeps its method-local quantifiers and // constraints on the inferred type. Instantiating them here matches the diff --git a/crates/psrs-typecheck/src/typecheck/infer/mod.rs b/crates/psrs-typecheck/src/typecheck/infer/mod.rs index 65ce7e8e..57318f0a 100644 --- a/crates/psrs-typecheck/src/typecheck/infer/mod.rs +++ b/crates/psrs-typecheck/src/typecheck/infer/mod.rs @@ -3,6 +3,7 @@ use super::*; mod application; mod case; mod construct; +mod coverage; mod expected; mod intrinsics; mod let_expr; diff --git a/crates/psrs-typecheck/src/typecheck/prim/compare/tests.rs b/crates/psrs-typecheck/src/typecheck/prim/compare/tests.rs index bb301101..2bdea968 100644 --- a/crates/psrs-typecheck/src/typecheck/prim/compare/tests.rs +++ b/crates/psrs-typecheck/src/typecheck/prim/compare/tests.rs @@ -56,6 +56,7 @@ pub(super) fn goal( report_span: constraint.span, givens: Vec::new(), solution: None, + witness: None, } } diff --git a/crates/psrs-typecheck/src/typecheck/prim/dispatch.rs b/crates/psrs-typecheck/src/typecheck/prim/dispatch.rs index 38d37d78..8c0d9f45 100644 --- a/crates/psrs-typecheck/src/typecheck/prim/dispatch.rs +++ b/crates/psrs-typecheck/src/typecheck/prim/dispatch.rs @@ -116,6 +116,7 @@ impl Checker { constraint.class_id, &args.resolved(self), constraint.span, + constraint.witness.as_deref(), )); return PrimitiveDispatch::Reported; } diff --git a/crates/psrs-typecheck/src/typecheck/prim/int.rs b/crates/psrs-typecheck/src/typecheck/prim/int.rs index 7466f39d..635d3a32 100644 --- a/crates/psrs-typecheck/src/typecheck/prim/int.rs +++ b/crates/psrs-typecheck/src/typecheck/prim/int.rs @@ -247,6 +247,7 @@ mod tests { report_span: constraint.span, givens: Vec::new(), solution: None, + witness: None, } } diff --git a/crates/psrs-typecheck/src/typecheck/prim/mod.rs b/crates/psrs-typecheck/src/typecheck/prim/mod.rs index b715eed7..c46d4f21 100644 --- a/crates/psrs-typecheck/src/typecheck/prim/mod.rs +++ b/crates/psrs-typecheck/src/typecheck/prim/mod.rs @@ -24,7 +24,7 @@ mod compare; mod dispatch; mod int; mod reflection; -mod reports; +pub(in crate::typecheck) mod reports; pub(in crate::typecheck) mod requeue; mod row; mod symbol; diff --git a/crates/psrs-typecheck/src/typecheck/prim/reports.rs b/crates/psrs-typecheck/src/typecheck/prim/reports.rs index e628c530..31932b0b 100644 --- a/crates/psrs-typecheck/src/typecheck/prim/reports.rs +++ b/crates/psrs-typecheck/src/typecheck/prim/reports.rs @@ -80,20 +80,34 @@ fn solve_warn(checker: &mut Checker, args: &PrimitiveArgs) -> PrimitiveOutcome { } /// Builds the official `NoInstanceFound` diagnostic for an unresolved report -/// obligation. A well-formed `Fail Doc` uses its custom message. `Partial` -/// currently has no exhaustiveness payload in HIR, so it keeps the ordinary -/// no-instance wording until the owning coverage pass can provide that data. +/// obligation. Coverage witnesses belong to the wanted, so report generation +/// preserves the case that produced a `Partial` obligation. pub(in crate::typecheck) fn unresolved_report_error( checker: &Checker, class_id: hir::TypeId, arguments: &[InferType], span: psrs_span::TextRange, + witness: Option<&[Vec]>, ) -> TypeCheckError { let message = if class_id == hir::TypeId::PRIM_TYPE_ERROR_FAIL { arguments .first() .and_then(|message| render_doc(checker, message)) .map(|message| format!("Custom error:\n{}", indent(&message, 2))) + } else if class_id == hir::TypeId::PRIM_PARTIAL { + witness.map(|rows| { + let cases = rows + .iter() + .map(|row| row.join(" ")) + .collect::>() + .join("\n"); + format!( + "A case expression could not be determined to cover all inputs.\n\n\ + The following additional cases are required to cover all inputs:\n{}\n ...\n\n\ + Alternatively, add a Partial constraint to the type of the enclosing value.", + indent(&cases, 2) + ) + }) } else { None }; diff --git a/crates/psrs-typecheck/src/typecheck/prim/row/tests/mod.rs b/crates/psrs-typecheck/src/typecheck/prim/row/tests/mod.rs index 9ab547c0..7f03d3e6 100644 --- a/crates/psrs-typecheck/src/typecheck/prim/row/tests/mod.rs +++ b/crates/psrs-typecheck/src/typecheck/prim/row/tests/mod.rs @@ -59,6 +59,7 @@ fn goal( report_span: constraint.span, givens: Vec::new(), solution: None, + witness: None, } } diff --git a/crates/psrs-typecheck/src/typecheck/prim/tests/mod.rs b/crates/psrs-typecheck/src/typecheck/prim/tests/mod.rs index 75d2f0d7..0381cd1e 100644 --- a/crates/psrs-typecheck/src/typecheck/prim/tests/mod.rs +++ b/crates/psrs-typecheck/src/typecheck/prim/tests/mod.rs @@ -390,6 +390,7 @@ fn obligation( report_span: constraint.span, givens: Vec::new(), solution: None, + witness: None, } } diff --git a/crates/psrs-typecheck/src/typecheck/type_model.rs b/crates/psrs-typecheck/src/typecheck/type_model.rs index d725e126..96dbbea3 100644 --- a/crates/psrs-typecheck/src/typecheck/type_model.rs +++ b/crates/psrs-typecheck/src/typecheck/type_model.rs @@ -35,6 +35,21 @@ pub(super) enum InferType { TypeLevelInt(i64), } +impl InferType { + /// Whether this type can guide predicative inference without retaining + /// universal or dictionary binders in a solved unknown. + pub(super) fn is_monotype(&self) -> bool { + match self { + Self::ForAll { .. } | Self::Constrained { .. } => false, + Self::Application(function, argument) => { + function.is_monotype() && argument.is_monotype() + } + Self::RowExtend { ty, tail, .. } => ty.is_monotype() && tail.is_monotype(), + _ => true, + } + } +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub(super) enum TypeConstructor { Function, diff --git a/crates/psrs-typecheck/src/typecheck/vocabulary.rs b/crates/psrs-typecheck/src/typecheck/vocabulary.rs index 8fb262e4..8f71c3fc 100644 --- a/crates/psrs-typecheck/src/typecheck/vocabulary.rs +++ b/crates/psrs-typecheck/src/typecheck/vocabulary.rs @@ -164,6 +164,12 @@ pub(super) struct WantedConstraint { pub(super) report_span: TextRange, pub(super) givens: Vec<(ClassConstraint, WantedSolution)>, pub(super) solution: Option, + /// The covered-input rows the coverage engine could not see, for the only + /// fresh obligation that owns one: a report-only wanted whose class is + /// `Partial`. Re-emitted scheme constraints leave this `None`, so a later + /// boundary reports its obligation without restating cases it never + /// analyzed. + pub(super) witness: Option>>, } /// A resolved type synonym, expanded during signature elaboration. diff --git a/docs/decision/DEC-22-labelled-record-storage.md b/docs/decision/DEC-22-labelled-record-storage.md new file mode 100644 index 00000000..ce5ad7ee --- /dev/null +++ b/docs/decision/DEC-22-labelled-record-storage.md @@ -0,0 +1,62 @@ +# DEC-22 — Labelled Storage for Open and Erased Records + +**Status:** Proposed +**Date:** 2026-10-17 + +## Context and constraints + +Closed typed products cannot represent an unknown row tail. The old canonical +product with erased fields also loses the field metadata needed when an erased +record later appears at an open-row use, including values extracted from an +Array or parameterized ADT. Dynamic `Record.Unsafe` keys make this an actual +library requirement rather than an optimization opportunity. + +The compiler must preserve the official record dictionaries and pure library +definitions, keep one producer/consumer representation contract, and support +generic, recursive, imported and higher-order uses. Choosing an index for one +closed call, recognizing reflected literal names, or requiring complete +monomorphization does not meet those constraints. + +## Decision + +Adopt one immutable labelled record-storage protocol for open rows and bare +erased record values. It consists of parallel arrays of canonical String labels +and recursively erased payloads, held by one header. Labels are unique and +sorted; lengths agree. Closed typed records retain specialized products and +cross this boundary through explicit pack/unpack operations. Complete closed +recovery validates the actual labels and lengths, rather than casting between +nominal products. + +The record owner publishes a checked schema containing the exact representation +handles. Structural reads consume it; they do not search for similar physical +products. Type checking owns row relations and field types, the payload owner +owns recursive erasure/recovery, and P9 owns physical GC types. Canonical ABI +views retain both the logical closed fields and their actual labelled storage. +MIR checks these views against their owner and physical definitions; recursive +list copies retain field descriptors until their actual accesses are emitted. + +Schema checks establish physical structure. They do not prove arbitrary runtime +array contents. Source construction and checked dynamic constructors own sorted, +unique, equal-length contents; runtime reads and closed recovery enforce their +required bounds and shape checks. Normal source typing remains a producer +obligation at the Core-to-CC boundary. Labels are runtime field names, not source +type IDs or dynamic type tags. + +The full design is [open record rows](../design/backend/fp/open-records.md); +the [execution checklist](../implementation/backend/open-records.md) controls +acceptance. This proposal is not a claim that every slice is implemented. + +## Consequences + +Generic records retain their complete tails through arrays, ADTs, closures and +ABI adaptation. Source record dictionaries can use ordinary typed storage +primitives without a second implementation of Eq, Ord, Show or row solving. +Immutable record conversion may allocate; nested array and nominal-reference +identity still follows the existing owners' policies. + +The representation costs a header, two arrays, field erasure, and label checks. +Closed operations keep direct products, and later optimizations may remove +proven-redundant conversions or share metadata. Correctness cannot depend on +those optimizations. JavaScript `undefined` and prototype behavior have no +implicit target representation: unsafe foreign adapters require explicit +differences and behavior evidence, without successful-looking placeholders. diff --git a/docs/design/D-04-suite-roadmap.md b/docs/design/D-04-suite-roadmap.md index 4a8ea6db..0f0c2399 100644 --- a/docs/design/D-04-suite-roadmap.md +++ b/docs/design/D-04-suite-roadmap.md @@ -7,9 +7,10 @@ > mechanisms described below were removed: the trusted `Effect` synthesis, the > `Prim.State` primitives, the `runWorld`/`runRegion` intrinsics, the Core/CC/MIR > State analysis, and the runtime storage source-binding contract. The `Effects` -> gate row and the `BE-21` `Effect Unit` entry capability no longer describe the -> compiler. No replacement Effect representation has been selected, so any -> measurement whose cases used either mechanism is historical. See the +> gate row and the compiler-owned `BE-21` `Effect Unit` entry mechanism no longer +> describe the compiler. The replacement is library-owned native Effect/ST; +> ordinary configured command normalization applies its runner. Measurements +> using either withdrawn mechanism are historical. See the > [removal handoff](../implementation/backend/effect-state-removal-handoff-2026-10-11.md). ## Purpose @@ -39,6 +40,13 @@ revision and include files in subdirectories: | `tests/purs/warning` | 68 | Must compile with listed warnings | | `tests/purs/optimize` | 10 | Expected optimizer output | +These are source-file counts. The `passing` directory has 365 program roots +and 74 support files. L6/M7 groups each root with its same-stem support +directory, following the official runner; support modules remain compilation +inputs rather than independent commands. Frontend boards keep their per-file +units. A corpus FFI dependency excludes its entire runtime program. See +[runtime program units](../implementation/runtime-program-scoreboard.md). + The vendored copy under `tests/upstream` keeps the four `.purs` directories only. `optimize` is not vendored, so M8-O reads it from a checkout, and its goldens are `.out.js` files: M8-O therefore compares optimizer output shape @@ -93,7 +101,7 @@ FFI. | M4 | Core type checking | type `errorCode`s | L4 | | M5 | Type classes and instances | class `errorCode`s | L5 | | M6 | Data, newtypes, records, rows | features required by `passing` | L4–L6 | -| M7 | Runtime and standard library | `passing` (413) run | L6 | +| M7 | Runtime and standard library | `passing` program groups (340 non-FFI) | L6 | | M8 | Warnings and optimization | `warning` (68), `optimize` (10) | — | ### M0 — Layout and lexing @@ -591,17 +599,21 @@ obligation to FE-13, and accepted mismatches still need type-checking fixes. - **Acceptance:** Agreement on the `errorCode`s above. - **Prerequisite:** M4. -**Measured current result (2026-10-17, `886cd82c`, annotations oracle, vendored corpus):** **77/97** +**Measured current result (2026-10-17, `7dd45e4b` plus the shared coverage and Partial slice, annotations oracle, vendored corpus):** **81/97** failing cases agree. Per-code agreement is `OverlappingInstances` 8/8, -`NoInstanceFound` 48/53, `MissingClassMember` 2/2, +`NoInstanceFound` 52/53, `MissingClassMember` 2/2, `InvalidNewtypeInstance` 6/6, `InvalidInstanceHead` 1/7, `DuplicateInstance` 1/1, `ClassInstanceArityMismatch` 4/4, and `CannotDeriveInvalidConstructorArg` 7/7. The remaining measured codes are `PossiblyInfiniteInstance` 0/1, `OrphanInstance` 0/7, and `DuplicateTypeClass` 0/1. Eleven cases are blocked before a mapped diagnostic: five P2 expression cases, four P2 hole cases, and two superclass-cycle cases. -The four arity cases now report their official code at the common instance -recorder; all structural deriving families have source and execution evidence. +The shared coverage/`Partial` slice adds agreement on `2806`, `4466`, +`NonExhaustivePatGuard`, and `Superclasses5`, all `NoInstanceFound`; L1–L4 +remain unchanged in the same release remeasurement. The prior same-day result +was 77/97 (`NoInstanceFound` 48/53). The four arity cases report their official +code at the common instance recorder; all structural deriving families have +source and execution evidence. The 2026-10-06 result was 75/97, with `NoInstanceFound` 46/53. The 2026-10-04 result was 58/81. Denominators reflect cases with their own mapped diagnostics; newly attributable deriving and arity cases enter the board rather than being read as regressions. @@ -730,17 +742,26 @@ the code reference and an immutable capture array. ### M7 — Runtime and standard library -- **Suite:** `tests/purs/passing`, 413 files after excluding 26 FFI tests; these - must compile and run with the expected observable result. +- **Suite:** `tests/purs/passing`, 340 program groups after excluding 25 FFI + groups. Each root includes its same-stem support sources and must compile + and run with the expected observable result. - **Goal:** Module loading, the PureScript-facing standard library (Prelude, `Effect`, console, assertions), closure conversion, and a runtime that executes the artifacts. -- **Acceptance:** Each passing test compiles and runs; failures are reported per - file. +- **Acceptance:** Each passing program compiles and runs; failures are reported + per program. Runtime output is recorded, not compared with upstream goldens. - **Prerequisite:** M2–M6. -**Progress (measured 2026-10-17 by `runtime::l6_runtime_scoreboard` at `886cd82c`):** **273 of 413** -non-FFI `passing` files compile, validate, and run; all 273 exit 0. The other 140 do not agree. The gate table below records the stage split. Twenty-six FFI files stay excluded. +**Progress (remeasured 2026-10-17, release harness, `fe6dd78f` plus the checked command-runner, program-grouping and contextual application-checking changes):** **273 of 340** non-FFI programs compile, validate, and run; all 273 exit 0. The other 67 do not agree. The 365 roots include 74 support sources as inputs; 25 FFI programs are excluded. The gate table below records the stage split. The locked package is `6fcef6ccbab1f3459af2573bbef4ae25b86852d4`, fingerprint `fnv1a64-v1:5fcbb57525c63eb1`. + +The previous **273/413** was a per-file measurement that incorrectly ran +support modules as commands. Its 66 `Data.Symbol.unsafeCoerce` failures are all +entry-less support modules, not 66 executable compiler regressions. The new +program-wide FFI rule also excludes `ForeignKind.purs` through its support +source. The actual command-runner repair executes unchanged `Sequence.purs`, +whose action returns `List Unit`. These counts are different units, not an +additive recovery of 66 programs. See [runtime program units](../implementation/runtime-program-scoreboard.md) +and [checked command instantiation](../implementation/backend/polymorphic-command-runner.md). **Earlier progress:** **210 of 413** non-FFI `passing` files compile, validate, and run; all exit 0. Twenty-six FFI @@ -890,7 +911,7 @@ The failure path is now landed rather than assumed: `Prelude.trap` is a message and then escapes through it, and the vertical tests assert the trap rather than an exit code (`PSRS_REQUIRE_WASMTIME=1 cargo test -p psrs-driver --lib tests::assertions`). That measurement's board was still 0/413. The -current board is the 273/413 row in the gate table. The 164/413 and 210/413 +current board is the 287/340 program row in the gate table. The 164/413 and 210/413 figures in this section are earlier measurements. The L2 run reports 19 sibling modules loaded and no case blocked because the @@ -1062,6 +1083,23 @@ one thing so there is no second source of truth: `Area`, or `Phase` as project fields; those are the labels and the milestone, and duplicating them would be a second source of truth. +For passing-runtime blockers, `Corpus cases` counts program groups. A support +module contributes only through its parent's first blocker. A zero count does +not prove unexercised library APIs or close their issue: #124's former 66 +Symbol blockers were support modules run as commands, and its corrected count +is zero while broader library acceptance remains open. Frontend failing-case +counts retain their source-file and error-code units. + +The next #124 API slice verifies public `Data.Symbol.reifySymbol` independently +of that corrected blocker count. The library preserves the pinned pure body +and adapts its private coercion foreign through `Unsafe.Coerce`. Forty official +JS/target value observations, dynamic callback/closure/action execution, failure +propagation and two diagnostic rejections pass; see the +[contextual application acceptance record](../implementation/frontend/contextual-application-results.md). +#124 remains open. #95 is Blocked on `Test.Assert.checkThrows`: the default +target has no exception/trap recovery, so its one first-blocking program is not +counted as supported. + Dependencies carry the blocking order that is not visible from the phase number. The phase epics are chained (#74 blocks #75 blocks #76 …) except that Phase 0 is independent. Below the epics, the edges that matter are @@ -1100,15 +1138,15 @@ for matrix status. | --- | --- | --- | --- | | L0 | Layout goldens | 15/15 official parse outcomes agree (12 accepted, 3 rejected), enforced by regression tests. | 15/15 agreement, with all layout cases covered by regression tests. | | L1 | Non-excluded parse behavior | 904/908 agreement using the annotations oracle; `passing` 410/413, `failing` 412/413, `warning` 67/67, `layout` 15/15, with the four remaining cases recorded as DEC-16 intentional differences | 100% agreement apart from the DEC-16 intentional differences. | -| L2 | Module, import, export, and name resolution | **72/72** failing cases. `passing` resolution is **403/413**; the remaining 10 stop at P3 (6) or P0 (4), with no harness, missing-library, or unusable-sibling blockers. Remeasured with the annotations oracle on 2026-10-07 after the shared-import-qualifier repair. | The mapped resolution cases and all required passing-module cases agree. | +| L2 | Module, import, export, and name resolution | **72/72** failing cases. `passing` resolution is **403/413**; the remaining 10 stop at P3 (6) or P0 (4), with no harness, missing-library, or unusable-sibling blockers. Reconfirmed with the annotations oracle on 2026-10-17 using the release harness and current locked package. | The mapped resolution cases and all required passing-module cases agree. | | L3 | Kinds and higher-kinded types | 39/48 failing cases: `KindsDoNotUnify` 16/24, `PartiallyAppliedSynonym` 12/12, `CycleInTypeSynonym` 4/4, `CycleInKindDeclaration` 2/2, `InfiniteKind` 2/2, and `UndefinedTypeVariable` 3/4. | 100% agreement for the mapped kind cases. | | L4 | Core type checking | 39/50 failing cases. The remaining mismatches include kind diagnostics reported in place of `ExpectedType`, missing `EscapedSkolem`, `VisibleTypeApplications1`, and five `Coercible` cases reported as `NoInstanceFound`. | 100% agreement for the mapped type cases. | -| L5 | Classes and instances | 77/97 failing cases: `OverlappingInstances` 8/8, `NoInstanceFound` 48/53, `MissingClassMember` 2/2, `InvalidNewtypeInstance` 6/6, `DuplicateInstance` 1/1, `InvalidInstanceHead` 1/7, `ClassInstanceArityMismatch` 4/4, `CannotDeriveInvalidConstructorArg` 7/7; `PossiblyInfiniteInstance` 0/1, `OrphanInstance` 0/7, `DuplicateTypeClass` 0/1. Remeasured with the annotations oracle on 2026-10-17 (`886cd82c`, vendored corpus). The 2026-10-06 result was 75/97. | 100% agreement for the mapped class cases. | -| L6/M7 | Runtime and standard library | **273/413** non-FFI passing files compile, validate, and run, all with exit code 0. No case traps. The other 140 do not agree: 69 fail while linking a library foreign (`Data.Symbol.unsafeCoerce` 66, `Record.Unsafe.unsafeGet` 2, `Test.Assert.checkThrows` 1), 34 stop at P5 typecheck, 13 at P8 closure conversion, 11 at P5 kind check, 6 at P3, 4 at P0, 1 at P7 command runner (`Sequence.purs`), 1 times out at runtime (`2288.purs`, 10s), and `BigFunction.purs` is killed before a diagnostic. Remeasured on 2026-10-17 at `886cd82c` against the vendored corpus (Wasmtime 49.0.3, `purs` 0.15.16). Each file ran in its own scoreboard process. The 2026-10-04 result was 164/413. | Every in-scope passing file for the feature compiles, validates, and runs with the expected result. | +| L5 | Classes and instances | 81/97 failing cases: `OverlappingInstances` 8/8, `NoInstanceFound` 52/53, `MissingClassMember` 2/2, `InvalidNewtypeInstance` 6/6, `DuplicateInstance` 1/1, `InvalidInstanceHead` 1/7, `ClassInstanceArityMismatch` 4/4, `CannotDeriveInvalidConstructorArg` 7/7; `PossiblyInfiniteInstance` 0/1, `OrphanInstance` 0/7, `DuplicateTypeClass` 0/1. Remeasured with the annotations oracle on 2026-10-17 (release harness, vendored corpus, current locked package). The 2026-10-06 result was 75/97. | 100% agreement for the mapped class cases. | +| L6/M7 | Runtime and standard library | **287/340** non-FFI programs compile, validate, and run, all with exit code 0; no case traps. Of 365 roots, 25 FFI groups are excluded and 74 support sources remain inputs. The other 53 are 31 P5 typecheck, 11 P5 kind check, 2 P8 closure/linking (one recursive local binding and the missing `Test.Assert.checkThrows` foreign in `TCOMutRec.purs`), 5 P3 resolve, 2 P0 lex (DEC-16), the 10-second runtime timeout in `2288.purs`, and the bounded `BigFunction.purs` compilation. The shared coverage/`Partial` slice adds `Let`, `ShadowedTCOLet`, and `Where`, with no regressions. Remeasured 2026-10-18 after removing mandatory local-row specialization, with no success-set changes, at `7dd45e4b` plus the changes in [the same-day handoff](../implementation/backend/open-records-handoff-2026-10-17.md), release suite SHA-256 `52a9b77e706fd2030ed8c235c75f3a313ee118a18de0b49e4be21eb375e03df1`, required Wasmtime 49.0.3, and package `e556bf2f` / `fnv1a64-v1:7d96f1c44d8d52cc`. BigFunction ran separately with a 90-second cap and 2 GiB RSS guard; it reached the RSS guard at 27.89 seconds with peak RSS 2,141,824 KiB. The 2026-10-18 integrated [open-record review](../implementation/backend/open-records.md#integrated-topic-acceptance-review-2026-10-18) re-measured this tree with an identical result — the same failure set and suite identity, BigFunction at the guard in 33.39 seconds with peak RSS 2,137,584 KiB, run-to-run variation — completing the open-record checklist. | Every in-scope passing program for the feature compiles, validates, and runs with the expected result. | | M8-W | Warnings | 67 non-FFI warning files are in scope; no warning-code scoreboard exists | Warning-code agreement reaches 100% for the tracked warning corpus. | | M8-O | Optimization | 10 optimize files are in scope; they are not vendored and their goldens are JavaScript output | Expected optimize/CoreFn output agrees for all tracked optimize files. | -L1–L4 were reconfirmed on 2026-10-17 at `886cd82c` against the vendored corpus (`PSRS_ORACLE=annotations`, `purs` 0.15.16): L1 904/908, L2 72/72 and passing resolution 403/413, L3 39/48, L4 39/50. L5 moved from 75/97 to 77/97 the same day. L6/M7 moved from 164/413 to 273/413. Earlier M7 tables in the progress section record the `logShow`, `Show`, Foldable, and `Data.Functor` runs; those figures are historical and are not added to this table. The current L5 denominator is 97. +L1–L5 were reconfirmed on 2026-10-17 at `7dd45e4b` plus the shared coverage and `Partial` slice recorded in [the same-day handoff](../implementation/backend/open-records-handoff-2026-10-17.md), using the release harness, `PSRS_ORACLE=annotations`, `purs` 0.15.16 and package `e556bf2f` / `fnv1a64-v1:7d96f1c44d8d52cc`: L1 904/908, L2 72/72 and passing resolution 403/413, L3 39/48, L4 39/50, L5 81/97. The previous same-day frontend checkpoint used package `6fcef6cc` / `fnv1a64-v1:5fcbb57525c63eb1` and measured L5 77/97. The frontend units remain source files; L6/M7 uses program groups as recorded in the gate row. The earlier 273/413, 210/413 and 164/413 runtime records used per-file units and are historical, not additive with the program board. L5 keeps denominator 97. ### Feature-to-gate crosswalk @@ -1157,16 +1195,16 @@ resolved, type checked, and represented in Typed Core as required. | FE-10 | Type constructors, type application, type synonyms, and saturation | Constructor/application types, built-in and user constructors, and synonym substitution work in a restricted set. | Partial | Complete constructor environments, arity rules, recursive synonyms, and backend-independent acceptance. | | FE-11 | Kinds, kind signatures, higher-kinded types, kind annotations, and kind variables | Dedicated kind inference/checking covers several declarations, annotations, records/rows, and official kind errors. Kind checking runs once per program and produces the checked kind and role environment every module's type check consumes, with each diagnostic attributed to the module that declares the offending type and a missing scheme reported instead of inferred; one primitive table is the only reading of a primitive's kind, and the `Coercible` solver consumes that shared denotation and kind solver. A declaration's scheme quantifies only the kind unknowns its own definition leaves undetermined, but the well-scoped-quantification rule (`QuantificationCheckFailureInKind`) is not implemented, and an instance head whose class is declared in another module is still skipped. | Partial | Add the well-scoped-quantification rule, cross-module instance heads, kind checking in expressions, and type-level row functions. | | FE-12 | Algebraic data types, constructors, newtypes, and constructor typing | Data/newtype declarations, constructor schemes, constructor application, and basic case typing work. | Partial | Add full recursive/parameterized checking, exhaustiveness, and all pattern forms. | -| FE-13 | Records, row types, row polymorphism, and variants | Closed concrete records, field access/update, partial source record patterns, exact generated product patterns, and row unification work. The row normalizer returns collected fields and tail or an `InvalidShape` diagnostic; it does not treat an unrecognized shape as a closed row. The `Prim.Row` and `Prim.RowList` relations use that normalizer and the shared kind solver. #87 tracks row syntax under FE-17; #120 covers the primitive row constraints under FE-14. Rigid-tail row unification still has known bugs. Labels are scalar sequences under [DEC-16](../decision/DEC-16-scalar-strings-and-utf8-storage.md); the current compiler still uses Rust strings and does not preserve lone surrogates. A label that exists only in an unknown tail is not accessed or updated. Open rows still have no runtime layout, and variants are not implemented. | Partial | Fix rigid-tail row unification; complete row syntax and official coverage, then add variants without choosing a runtime field layout. | -| FE-14 | Constraints, type classes, superclasses, class members, and instances | Source constraint elaboration, contextual and multi-parameter instances, superclass evidence, imported generic dictionaries, ordered source instance chains, and rank-1 polymorphic method signatures execute. Superclass edges use `TypeTemplate` substitution, including constructed arguments such as `C (Array a)`; `a_superclass_edge_over_a_constructed_argument_runs_when_wasmtime_is_available` executes the `Gamma (Array a)` edge through an `Epsilon Int` use. Instance member signatures are associated with consecutive equations, resolve instance-head variables, and are checked against specialized class method types; source and Wasmtime tests cover specialized and more-general signatures plus mismatches. A constrained instance-member annotation cannot yet be solved solely to specialize it to a monomorphic expected method type. Duplicate member groups, orphan member signatures, duplicate named instances, and ordinary-value/name collisions use official diagnostics. Explicit export lists filter instance branches by class/head/context visibility while preserving identity and chain positions; imports do not require the class in their selective list to receive visible instances. Class-only imports keep methods out of the ordinary value namespace. Constrained-forall expression ascriptions are checked in a complete rollback probe, then elaborated at their expected use type; ordinary class dictionaries survive both monomorphic and rank-N uses, with actual Wasmtime execution evidence. #120 adds compiler-owned rules for the twelve `Prim.Row`, `Prim.RowList`, `Prim.Symbol`, and `Prim.Int` relations and a report path for `Prim.TypeError.Fail`, `Warn`, and `Partial`. Open-row `Lacks` and `Union` preserve partial evidence and re-enter solving on residual constraints; the outcome contract belongs to each rule, while the shared framework checks returned dictionary arguments and bounds speculative work. `Warn` propagates through explicit scoped dictionaries; otherwise it emits at the enclosing value or instance declaration and discharges, so inferred schemes do not retain a `Warn` context; `Fail` renders a valid `Doc` as a custom error, while malformed `Doc` falls back to `NoInstanceFound`. `Partial` still lacks HIR exhaustiveness metadata and therefore reports the generic no-instance message. Five `Coercible` board mismatches remain because the proof evidence has no relation arguments for the shared dictionary verification path. The [primitives topic](frontend/type-system/prim.md) owns these semantics and their measured coverage. A wildcard in an instance head is rejected as `InvalidInstanceHead`; a wildcard in an instance context stays legal because the constraint is still solvable. Scoped method-local constraints and quantified method parameters have evidence in the [rank-N acceptance record](../implementation/frontend/rank-n.md). The standard library now declares its first classes: `Data.Semigroup` defines `Semigroup`, its `append` method, and the `<>` alias, with `String`, `Unit`, and `Array a` instances resolved across the module graph and re-exported from `Prelude`. `Data.Monoid` adds `mempty` for those three types, `Data.Show` adds the `Show` class and `show` for `Boolean`, `Int`, `Number`, `Char`, `String`, `Unit`, and `Array a`, and `Data.Foldable` adds `foldr`, `foldl`, and `foldMap` for `Array`, `Maybe`, and `Either a`. `fold` is not part of that surface yet, because a polymorphic `foldMap` of the identity does not lower. | Partial | Reconcile remaining class-rule mismatches and official-suite coverage; deriving is tracked under FE-22. | +| FE-13 | Records, row types, row polymorphism, and variants | Closed concrete records, field access/update, partial source record patterns, exact generated product patterns, and row unification work. Open rows and bare erased record values now use one immutable labelled store; known fields and record patterns select by label, and updates retain the unknown tail. The library owns the official `Record.Unsafe` wrappers and searches through checked source-typed record primitives. Runtime acceptance is recorded in [the open-record checklist](../implementation/backend/open-records.md), whose 2026-10-18 integrated review resolved the broader generic/recursive/imported interactions and malformed-IR rejection items and re-measured the L6/M7 board. The row normalizer returns collected fields and tail or an `InvalidShape` diagnostic; it does not treat an unrecognized shape as a closed row. The `Prim.Row` and `Prim.RowList` relations use that normalizer and the shared kind solver. #87 tracks row syntax under FE-17; #120 covers the primitive row constraints under FE-14. Rigid-tail row unification still has known bugs. Labels are scalar sequences under [DEC-16](../decision/DEC-16-scalar-strings-and-utf8-storage.md); the current compiler still uses Rust strings and does not preserve lone surrogates. A label that exists only in an unknown tail is not accessed or updated. Pattern-driven `..rest` binding and variants remain unsupported. | Partial | Fix rigid-tail row unification; complete row syntax and official coverage, then finish the open-record acceptance checklist and implement variants under an explicit layout decision. | +| FE-14 | Constraints, type classes, superclasses, class members, and instances | Source constraint elaboration, contextual and multi-parameter instances, superclass evidence, imported generic dictionaries, ordered source instance chains, and rank-1 polymorphic method signatures execute. Superclass edges use `TypeTemplate` substitution, including constructed arguments such as `C (Array a)`; `a_superclass_edge_over_a_constructed_argument_runs_when_wasmtime_is_available` executes the `Gamma (Array a)` edge through an `Epsilon Int` use. Instance member signatures are associated with consecutive equations, resolve instance-head variables, and are checked against specialized class method types; source and Wasmtime tests cover specialized and more-general signatures plus mismatches. A constrained instance-member annotation cannot yet be solved solely to specialize it to a monomorphic expected method type. Duplicate member groups, orphan member signatures, duplicate named instances, and ordinary-value/name collisions use official diagnostics. Explicit export lists filter instance branches by class/head/context visibility while preserving identity and chain positions; imports do not require the class in their selective list to receive visible instances. Class-only imports keep methods out of the ordinary value namespace. Constrained-forall expression ascriptions are checked in a complete rollback probe, then elaborated at their expected use type; ordinary class dictionaries survive both monomorphic and rank-N uses, with actual Wasmtime execution evidence. #120 adds compiler-owned rules for the twelve `Prim.Row`, `Prim.RowList`, `Prim.Symbol`, and `Prim.Int` relations and a report path for `Prim.TypeError.Fail`, `Warn`, and `Partial`. Open-row `Lacks` and `Union` preserve partial evidence and re-enter solving on residual constraints; the outcome contract belongs to each rule, while the shared framework checks returned dictionary arguments and bounds speculative work. `Warn` propagates through explicit scoped dictionaries; otherwise it emits at the enclosing value or instance declaration and discharges, so inferred schemes do not retain a `Warn` context; `Fail` renders a valid `Doc` as a custom error, while malformed `Doc` falls back to `NoInstanceFound`. `Partial` now receives missing-case metadata from the shared coverage engine, so exhausted cases discharge the obligation and annotated non-exhaustive ones report the official wording with a witness. Five `Coercible` board mismatches remain because the proof evidence has no relation arguments for the shared dictionary verification path. The [primitives topic](frontend/type-system/prim.md) owns these semantics and their measured coverage. A wildcard in an instance head is rejected as `InvalidInstanceHead`; a wildcard in an instance context stays legal because the constraint is still solvable. Scoped method-local constraints and quantified method parameters have evidence in the [rank-N acceptance record](../implementation/frontend/rank-n.md). The standard library now declares its first classes: `Data.Semigroup` defines `Semigroup`, its `append` method, and the `<>` alias, with `String`, `Unit`, and `Array a` instances resolved across the module graph and re-exported from `Prelude`. `Data.Monoid` adds `mempty` for those three types, `Data.Show` adds the `Show` class and `show` for `Boolean`, `Int`, `Number`, `Char`, `String`, `Unit`, and `Array a`, and `Data.Foldable` adds `foldr`, `foldl`, and `foldMap` for `Array`, `Maybe`, and `Either a`. `fold` is not part of that surface yet, because a polymorphic `foldMap` of the identity does not lower. | Partial | Reconcile remaining class-rule mismatches and official-suite coverage; deriving is tracked under FE-22. | | FE-15 | Functional dependencies | Source fundep improvement uses transitive determining closure and selected branches; independent class arguments still prove apartness. Ambiguity and consistency diagnostics are covered. One known divergence: a type wildcard standing for a fundep-determined position is reported as a fundep conflict, where `purs` only warns — `passing/WildcardInInstance.purs` says so in its own comment. It is unmeasurable until Phase 3 provides `Effect` and `Effect.Console`. | Partial | Reconcile official-suite fundep coverage and remaining advanced class forms. | | FE-16 | Roles and `Coercible` | Role inference/checking (including imported aliases), compiler-owned `Coercible` solving, checked kind compatibility, higher-kinded given rewriting, canonical open-row alignment, constructor-visibility checks, explicit Typed Core evidence boundaries, and backend-planned conversions work for the covered subset. Source and Wasmtime tests cover phantom/nominal/representational roles, parameterized newtype scalar/function/array payloads, and cross-module dictionaries. Scoped method-local constraints on class methods are covered under FE-18. The `Coercible` solver reads primitive kinds through the shared kind checker; expression-level kind checking remains incomplete. Five `Coercible` board mismatches remain because a `Proof` member has no dictionary arguments for the framework's decided-argument check, so its answer still needs an explicit verification contract. | Partial | Expand closure-capture runtime support and the remaining coercion cases. See [roles and coercions acceptance](../implementation/frontend/roles-and-coercions.md). | | FE-17 | Visible type application, typed binders, type wildcards, holes, and advanced annotations | Typed binders preserve and check scoped annotations, and each source type wildcard receives fresh kind/type variables through the shared type spine. Type-level `String` and `Int` literals are ordinary spine nodes: a signature may contain them, they unify by value, and they survive into THIR where the verifier compares them. A wildcard in a value signature is solved by unification and is accepted in every shape `purs` accepts; a wildcard in an instance head is rejected as `InvalidInstanceHead`, while one in an instance context stays legal. The `1664.purs` wildcard binder lowers through P2. Visible term type application, wildcard warning/error behavior, higher-kinded application, and non-generalized hole diagnostics remain incomplete. The `Type`, `Constraint`, and `Symbol` heads are accepted as ordinary type constructors with their declared primitive kinds. Official's CST has no kind-application node; its kind checker synthesizes `KindApp` while instantiating a polymorphic kind, and this compiler performs that instantiation in the kind solver, so its source type spine needs no `KindApplication` node. The source forms that do name a kind or type explicitly are separate nodes. #87 lands both of the forms that blocked P2: a negative type-level integer prefix is the negative literal on the shared spine, and a visible type application `e @T` is elaborated by the checker, which substitutes the written argument for the operand's outermost quantifier after checking it against that quantifier's kind, and is erased at runtime. No P2 surface-lowering case remains. Three limits are recorded rather than approximated. A chained application `f @A @B` is reported, because the quantifiers an application leaves behind are scheme variables here and choosing between them needs the scheme to record which variables a visible application has consumed. A visible application on a class-method head is unresolved, which is `failing/ClassHeadNoVTA3.purs`. And this compiler's CST does not carry the binder visibility that official's `CST/Convert.hs` derives from `forall @a.`, so a plain `forall a.` binder is selectable where `purs` rejects it — the permissive direction, and the remaining half of `failing/VisibleTypeApplications1.purs`. `CannotApplyExpressionOfTypeOnType` and `CannotSkipTypeApplication` are the mapped codes. The primitive row relations themselves all have rules, and the row-side gap that remains is the rigid-tail unification defect under FE-13. | Partial | Model `forall` binder visibility so a visible application matches official, then resolve chained applications and class-method heads. | | FE-18 | Higher-rank types, subsumption, impredicativity, and higher-rank `forall` | Bidirectional checking preserves nested quantifiers, checks directional function/record subsumption, and rejects escaping skolems and specialized universal arguments. Source and GC execution cases cover rank-2 through rank-4, fields, returned and captured values, recursive annotations, higher-kinded parameters, and nested constraints. See the [rank-N acceptance record](../implementation/frontend/rank-n.md) for verification evidence and the official differential battery. | Partial | Reconcile the complete official higher-rank/skolem corpus, including its library dependencies and separate higher-rank kind requirements; track visible type application and diagnostic agreement. | | FE-19 | Foreign declarations and target-aware external names | Source-declared WIT bindings are resolved for the supported backend path. `foreign import data` is a nominal opaque type with no constructors; a nullary one maps to a WIT resource. THIR and Core keep it as `Constructor(User(id))` plus `opaque_ids`, distinct from `Int` (`lowers_an_opaque_foreign_type_to_core_without_collapsing_it_to_int`). JavaScript FFI is not a frontend target. CC/MIR handle layout is not done. | Partial | Finish target-aware foreign value rules beyond the supported WIT subset. Resource lifetime and handle layout stay in the backend. | -| FE-20 | Warnings, holes, source spans, and official diagnostic codes | Source spans exist and resolution, kind, type, and class `errorCode`s are measured: L1 904/908, L2 72/72 (remeasured 2026-10-07), L3 39/48, L4 39/50, L5 77/97 (remeasured 2026-10-17). L4 per-code agreement is `TypesDoNotUnify` 34/41, `IntOutOfRange` 1/1, `InfiniteType` 2/2, `ExpectedType` 0/2, `EscapedSkolem` 1/2, `CannotApplyExpressionOfTypeOnType` 1/2, and `AmbiguousTypeVariables` 1/1. `MultipleErrors.purs` repeats its annotation, so code-occurrence counts sum to 40/51 while case agreement remains 39/50. Pattern-binder diagnostics match the annotated duplicate-name cases; warning coverage and complete diagnostic agreement remain open. Non-generalized hole diagnostics remain tracked under FE-17. | Partial | Add the missing class checks (#97) and track warning-code agreement separately from acceptance errors. | +| FE-20 | Warnings, holes, source spans, and official diagnostic codes | Source spans exist and resolution, kind, type, and class `errorCode`s are measured: L1 904/908, L2 72/72 (remeasured 2026-10-07), L3 39/48, L4 39/50, L5 81/97 (remeasured 2026-10-17). L4 per-code agreement is `TypesDoNotUnify` 34/41, `IntOutOfRange` 1/1, `InfiniteType` 2/2, `ExpectedType` 0/2, `EscapedSkolem` 1/2, `CannotApplyExpressionOfTypeOnType` 1/2, and `AmbiguousTypeVariables` 1/1. `MultipleErrors.purs` repeats its annotation, so code-occurrence counts sum to 40/51 while case agreement remains 39/50. Pattern-binder diagnostics match the annotated duplicate-name cases; warning coverage and complete diagnostic agreement remain open. Non-generalized hole diagnostics remain tracked under FE-17. | Partial | Add the missing class checks (#97) and track warning-code agreement separately from acceptance errors. | | FE-21 | Typed Core normalization and CoreFn/optimization compatibility | Typed Core lowering and verification work for the supported subset; official optimize output is not yet a target. | Partial | Add Core optimization passes and an explicit optimize compatibility track. | -| FE-22 | Deriving and newtype-based derivation | Every structural family generates ordinary checked instance members through one identity registry and syntax builder. All mapping families, folds, and traversals consume shared usage/variance analysis, including record fields, given higher-kinded dictionaries, and scoped forall parameters. Source tests cover every deriving diagnostic; differential batteries cover 18 original rules, 17 remaining-family/context/scoped cases, and six diagnostic cases. 25 Wasmtime cases cover all structural classes, Generic tag/field round trips, function Contravariant, polymorphic newtype adapters, applied-variable Eq1/Ord1 dispatch, both fold directions, sequencing, and multi-field traversal. L5 is 77/97, with deriving-specific `InvalidNewtypeInstance` 6/6 and `CannotDeriveInvalidConstructorArg` 7/7. | Partial | Shared declaration checks still mismatch the official orphan and invalid record/synonym instance-head cases, including `3405`, `3510`, and `InvalidDerivedInstance2`. See the [deriving design](frontend/type-system/deriving.md) and [acceptance record](../implementation/frontend/deriving.md). | +| FE-22 | Deriving and newtype-based derivation | Every structural family generates ordinary checked instance members through one identity registry and syntax builder. All mapping families, folds, and traversals consume shared usage/variance analysis, including record fields, given higher-kinded dictionaries, and scoped forall parameters. Source tests cover every deriving diagnostic; differential batteries cover 18 original rules, 17 remaining-family/context/scoped cases, and six diagnostic cases. 25 Wasmtime cases cover all structural classes, Generic tag/field round trips, function Contravariant, polymorphic newtype adapters, applied-variable Eq1/Ord1 dispatch, both fold directions, sequencing, and multi-field traversal. L5 is 81/97, with deriving-specific `InvalidNewtypeInstance` 6/6 and `CannotDeriveInvalidConstructorArg` 7/7. | Partial | Shared declaration checks still mismatch the official orphan and invalid record/synonym instance-head cases, including `3405`, `3510`, and `InvalidDerivedInstance2`. See the [deriving design](frontend/type-system/deriving.md) and [acceptance record](../implementation/frontend/deriving.md). | The frontend landing order is: @@ -1205,13 +1243,13 @@ Wasm is the target encoding, and WIT/WASI are the platform integration layers. | BE-18 | Generic source-declared WIT imports | Compatible `Int`/`Boolean`/`Number` scalars, handles, and `list`/`string` imports lower through the canonical ABI with signature validation. A WIT `string` is a source `String` and a WIT `list` is `Array Int`, so the two no longer share a source type ([DEC-16](../decision/DEC-16-scalar-strings-and-utf8-storage.md)). Closed, directly flattened WIT records can contain nested `list` fields. | Partial | Add other aggregate WIT values, richer results, and user-library loading. | | BE-19 | WIT aggregate values and resources | Resource handles lower under [DEC-14](../decision/DEC-14-resource-handle-ownership.md): the compiler drops no handle on its own and exposes `resource.drop` to source, so the standard library owns the lifetime discipline; byte lists and closed WIT records with nested byte-list fields are classified and lowered in WIT field order. Indirect parameter tuples are allocated through `cabi_realloc`. Non-byte `list` of scalars, `bool`, `char`, strings, nullary enums, flags, resource handles, and directly flattened records of scalar or string fields is copied between a source GC array and the canonical buffer, with a driver execution test for `list` and synthesized Wasm fixtures for `list`, `list`, and `list` ([ABI-08](../implementation/backend/linear-memory-and-canonical-abi.md) In progress). `option`, `result`, and non-unit `variant` are classified and validated against `Data.Maybe.Maybe`, `Data.Either.Either`, and a source data type, CC derives their variant representation and a concrete payload tree, and MIR branches on each tag and rebuilds the source value recursively for a scalar payload of any width (`s8`..`u64`, `f32`/`f64`), a byte or non-byte list, `flags`, a closed record, and a nested `option`/`result`/`variant`, recursing through record fields and a `list`/`list` element, with synthesized Wasm fixtures ([DEC-13](../decision/DEC-13-wit-to-source-type-mapping.md)); a large aggregate return area is allocated through `cabi_realloc`, a handle in a result is an ordinary value the standard library drops explicitly, and an indirect parameter record carries a mapped aggregate. The aggregate ABI is generated from one normalized canonical type ([compositional canonical ABI lowering](backend/wasm/canonical-abi-compositional.md)); the descriptor types and per-shape plans are removed. `list>`/`list`/`list` elements, nested `list>`, multi-word flags as list elements and in aggregates, non-byte `list`, and `list>` results are classified and lowered, and a unit-success `result<_, E>` maps to `Either E Unit` (the error on `Left`) and sizes its return area from the error payload. | Partial | Add general aggregate layouts beyond the list-and-handle subset. | | BE-20 | Component Model packaging and capability-based imports | `wit-component` lifts the core module to a WASI 0.2 component and prunes unused imports. | Partial | Add component import/export regression cases beyond the CLI path and pass the L6/M7 gate. | -| BE-21 | WASI CLI entry, exit, stdout, and stderr | `wasi:cli/run`, exit codes, console output, and error output work in the component path. A selected `Int` entry returns its value as the exit code. The former compiler-owned `Effect Unit` entry was removed on 2026-10-11. A compiler-selected non-`Int` entry is still rejected. Effect programs use the library `PSRS.Command.run` runner over the accepted native representation. Focused Wasmtime tests assert output and status for `Int` entries. The 2026-10-17 runtime board is 273/413. It has no P10 case with no selected `main`; `Sequence.purs` stops at P7 because its command entry type disagrees with the configured runner. This row stays Partial. | Partial | Pass the L6/M7 gate. | +| BE-21 | WASI CLI entry, exit, stdout, and stderr | `wasi:cli/run`, exit codes, console output, and error output work in the component path. A selected Int entry returns its value as the exit code. The former compiler-owned Effect entry was removed on 2026-10-11. Ordinary configured command normalization checks a runner instance at the closed entry type and produces an Int target root. The library `PSRS.Command.run :: forall a. Effect a -> Int` executes once and discards the action result; unchanged `Sequence.purs` now executes with exact `Done` output and exit zero. The current runtime board is 287/340 programs. | Partial | Pass the L6/M7 gate. | | BE-22 | WASI clocks and randomness | Monotonic time and random bytes are wired through WASI and tested. | Partial | Expose the remaining clock/random library surface and pass the L6/M7 gate. | | BE-23 | WASI arguments, environment, and filesystem | Source wrappers for arguments, environment, and filesystem execute ([WASI-07](../implementation/backend/wasi-platform.md) Verified). This feature row was not remeasured with the 2026-10-16 native-effect fingerprint, and the L6/M7 gate is still open. | Partial | Pass the L6/M7 gate. | | BE-24 | WASI sockets and HTTP | Not part of the current synchronous portable-program target. | Excluded | Revisit as a separate platform scope after the core target is stable. | | BE-25 | WASI 0.3 async streams and futures | The current compiler targets synchronous WASI 0.2. | Planned | Revisit only with an explicit platform decision and async language/library plan. | | BE-26 | Standard library and user module loading | User modules are discovered from the entry files' directories and linked transitively ([WASI-09](../implementation/backend/wasi-platform.md) Verified); the PureScript-facing standard library is loaded from `stdlib/lib` in trusted-prefix order ([WASI-10](../implementation/backend/wasi-platform.md) Verified). | Partial | Pass the L6/M7 module-loading scoreboard. | -| BE-27 | Wasm/WASI execution and official passing-suite runtime coverage | The `l6_runtime_scoreboard` compiles, validates, and executes the 413 non-FFI `passing` files under required Wasmtime. The 2026-10-17 measurement is **273/413**, all exiting 0 (Wasmtime 49.0.3, `purs` 0.15.16, compiler `886cd82c`). The other 140 are the L6/M7 split above. The 26 FFI files are excluded. The row stays Partial because L6 is not complete. | Partial | Resolve the remaining compile and runtime blockers, then remeasure the full board. | +| BE-27 | Wasm/WASI execution and official passing-suite runtime coverage | The release `l6_runtime_scoreboard` compiles, validates, and executes program groups under required Wasmtime. The latest 2026-10-18 measurement is **287/340**, all exiting zero, with 25 FFI groups excluded (Wasmtime 49.0.3; compiler `7dd45e4b` plus the shared coverage/`Partial` slice and generic local-row follow-through; package `e556bf2f`). `Let`, `ShadowedTCOLet`, and `Where` join the agreeing set without regressions. The other 53 are the L6/M7 split above. The 74 support sources are included in their parent inputs. The row stays Partial because L6 is not complete and the board does not compare output goldens. | Partial | Resolve the remaining compile and runtime blockers, then remeasure the full board. | | BE-28 | JavaScript/Node.js FFI compatibility | Not emitted or executed by this backend. | Excluded | No work planned under this decision. | ### Topic implementation acceptance @@ -1230,7 +1268,7 @@ acceptance result. | Scalars and primitives | BE-04; FE-08 input | Re-baselined by DEC-10: SP-01..SP-12 are Verified, including the GC-string representation. | [SP-01..SP-12](../implementation/backend/scalars-and-primitives.md) | | Pattern matching | BE-05, BE-06; supporting BE-08, BE-09 | PM-01..PM-15 have implementation, verifier, and required execution evidence. PM-14 includes source-spanned Boolean redundancy and guarded fallthrough; broader feature rows retain their separate gates. | [PM-01..PM-15](../implementation/backend/pattern-matching.md) | | Delimited control and generic handlers | BE-02, BE-03, BE-05, BE-06, BE-12; supporting BE-21, BE-26 | Partial: the eight operations implement typed contract v2 with persistent queues and one guest context executor. Mandatory Wasmtime covers clause/return-clause scope, deep/shallow library policies, forwarding, abandonment, aggregate/GC captures, multi-shot replay, replay-local expiry, normal target-completion expiry and long request sequences. Parent-chain probes and post-GC live-heap measurements cover long deep and mixed-forwarding loops while independent non-tail extents remain distinct. Core owns shared checked carrier/callable contracts; immutable CC quotations and live optimized MIR witnesses retain source/target domains and reject incompatible locally proven operands. CC verifies factory operands against those witnesses. Predeclared semantic helper identities reject check/close swaps in CC and MIR calls, including tail calls; mandatory execution also covers 100,000 requests with tail-call capability disabled. Source carrier parameters, results and joins now share the preplanned nominal computation/owner shapes; control entry operands use their declared storage roles without an unconditional payload erasure round trip. Nominal machine storage fields and helper calls reject cross-wired queues, extents and ownership cells in CC/MIR, and CC/MIR check raw capture roles against local closure targets. CC/MIR also check that finish completion closes and restores fields from the same record, rejecting missing close and same-shaped boundary swaps. A predeclared raw-resumption binding now checks the dispatch checkpoint capture, raw capture slots, Resume construction and the saved checkpoint check before replay; MIR requires the check to dominate replay. CC/MIR now also check saved finish validation, original callback sharing and fresh live activation cells within rebuild iterations, including parent/restoration/advancement and terminal replay product identities. Target finishes now preserve one source context and enter its caller; finish copies preserve original extent/parent and append order. Replay entry checks current execution/dispatch ancestry and empty accumulators, with traversal/handoff/callback rebuild preserving active/parent state. Source context constructors now retain current Handle clauses, caller and saved work, with fresh or shared-finish lifetime operands; their transitions enter the inner computation with an empty queue and the new context/extent. Root Run factories now check empty queues, coherent root dispatch/execution extents and normal cleanup, preserving allocation evidence through Start inlining and rejecting sequential extent/status reuse while admitting exclusive initialization. Labelled Control rows over pinned VariantF execute deep forwarding and shallow residual policies. Complete pinned Run/Free dependency sources are imported unchanged; representative pure Run State, Choose and combined Reader/State/Writer/Except programs execute with observed values and log order. CC/MIR now check dispatch coverage and selected-case projections, required scoped clause application results and current operands, and Bind/Resume transition identities; scoped MIR applications must execute on the actual branch path. Complete remaining execution-step, dispatch selection and frame/context provenance, foreign capture, caught-trap cleanup and broader upstream-library API acceptance remain unverified. AE-11 host IO is Verified on 2026-10-16 in that checklist; the provenance items in this cell were not closed by that measurement. | [AE/AC checklist](../implementation/backend/algebraic-effects.md) | -| Effects | BE-21; supporting BE-02, BE-26 | The compiler-owned `Effect` protocol was withdrawn on 2026-10-11. The accepted replacement is library-owned `PSRS.Effect.Native`: NE-01–NE-13 and AE-11 are Verified on 2026-10-16 at fingerprint `fnv1a64-v1:ad19620ada1d2ecf`. `checkThrows`, Console timers, `Effect.Exception`, and `Effect.Aff` stay unimplemented JavaScript foreigns. The runtime board is 273/413 as of 2026-10-17, so BE-21 stays Partial. The independent package commit and lock revision are `4c73aab52226e8f5e7b1f17052f1a19d717a472e`. | [NE-01..NE-13](../implementation/backend/native-effect-and-st.md), [AE-11](../implementation/backend/algebraic-effects.md) | +| Effects | BE-21; supporting BE-02, BE-26 | The compiler-owned `Effect` protocol was withdrawn on 2026-10-11. The accepted replacement is library-owned `PSRS.Effect.Native`: NE-01–NE-13 and AE-11 are Verified on 2026-10-16 at fingerprint `fnv1a64-v1:ad19620ada1d2ecf`. `checkThrows`, Console timers, `Effect.Exception`, and `Effect.Aff` stay unimplemented JavaScript foreigns. The runtime board is 287/340 program groups, so BE-21 stays Partial. The current package lock is `e556bf2f8cb377c049d2ff0a5a97b484c7b36bc5` / `fnv1a64-v1:7d96f1c44d8d52cc`, including the checked record-storage primitives, [official record wrappers](../implementation/backend/open-records.md) and the earlier [checked polymorphic command runner](../implementation/backend/polymorphic-command-runner.md) and [Symbol reification](../implementation/frontend/contextual-application-results.md). The earlier NE/AE fingerprint remains the historical acceptance baseline. | [NE-01..NE-13](../implementation/backend/native-effect-and-st.md), [AE-11](../implementation/backend/algebraic-effects.md) | | Type classes and dictionaries | BE-02, BE-09; FE-14/15 input | Backend acceptance complete from verified Typed Core fixtures: DICT-01..DICT-11 have implementation, verifier, and required execution evidence. Source constrained calls, contextual/imported generic instances, superclasses, fundeps, and ordered instance chains execute; FE-14/15 remain partial for remaining source class/fundep coverage, the constrained instance-member specialization limit, and official-suite acceptance. Class-method local constraints are covered under FE-18; deriving is tracked under FE-22. | [DICT-01..DICT-11](../implementation/backend/type-classes-and-dictionaries.md) | | Generic aggregate erasure | BE-08, BE-09, BE-10; supporting BE-02, BE-03, BE-13, BE-15 | Topic acceptance complete: all GA-01..GA-20 checks have implementation, verifier and required execution evidence. Broader feature rows retain their separate gates. | [Requirements, repair evidence, and validation](../implementation/backend/generic-aggregate-erasure.md) | | Optimization | BE-12 | Topic acceptance complete: OPT-01..OPT-14 have implementation, verifier, and required execution evidence. The official M8-O gate stays on the broader BE-12 row. | [OPT-01..OPT-14](../implementation/backend/optimization.md) | diff --git a/docs/design/backend/fp/library-owned-effects.md b/docs/design/backend/fp/library-owned-effects.md index 66343032..99035e39 100644 --- a/docs/design/backend/fp/library-owned-effects.md +++ b/docs/design/backend/fp/library-owned-effects.md @@ -397,16 +397,20 @@ Existing ordinary raw bindings retain their checked ABI and conservative effects they cannot be inserted into a state action without an explicit checked adapter. The package configures an ordinary runner such as -PSRS.Command.run :: Effect Unit -> Int. It invokes the unsafe library runner +`PSRS.Command.run :: forall a. Effect a -> Int`. It invokes the unsafe library runner once, returns zero on normal completion and propagates traps. The compiler -checks a configured monomorphic runner `T -> Int` against main's type T; +instantiates the configured runner at `T -> Int` against main's closed type T, +using Core's ordinary checked scheme-instantiation relation across the complete +callable type. Repeated variables must receive consistent replacements, and +nested quantifiers keep their ordinary scope. The runner's declared result must +be Int; the entry remains monomorphic. This also admits a monomorphic runner; it does not inspect whether T is Effect. Load its module as an explicit root, resolve stable identity once and build checked ordinary Core `runner main` before pruning. Preserve Main.main precedence, unique-main fallback and Int entries. Missing configuration, ambiguity and mismatches are explicit errors; check-only compilation needs no executable runner. Core entry normalization consumes resolved source identities and checked type -schemes, validates the monomorphic runner and selected entry, and constructs an +schemes, validates the runner's checked instance and selected entry, and constructs an ordinary application. The generated declaration gets a fresh symbol in the entry's source namespace and preserves its source range. Validation failure must leave the input Core unchanged. Run this conversion before reachability diff --git a/docs/design/backend/fp/native-effect-and-st-handlers.md b/docs/design/backend/fp/native-effect-and-st-handlers.md index 536c6869..e3455b18 100644 --- a/docs/design/backend/fp/native-effect-and-st-handlers.md +++ b/docs/design/backend/fp/native-effect-and-st-handlers.md @@ -1,7 +1,8 @@ # Native Effect and ST over Algebraic Handlers **Feature:** [F-02 — Build Portable Program Artifacts](../../../feature/F-02-portable-programs.md) -**Status:** Accepted 2026-10-16. NE-01–NE-13 are Verified in the [native checklist](../../../implementation/backend/native-effect-and-st.md). The independent package commit and compiler lock revision are `4c73aab52226e8f5e7b1f17052f1a19d717a472e`. The D-04 runtime board is 273/413 as of 2026-10-17. +**Status:** Accepted 2026-10-16. NE-01–NE-13 are Verified in the [native checklist](../../../implementation/backend/native-effect-and-st.md). The acceptance package commit was `4c73aab52226e8f5e7b1f17052f1a19d717a472e`; D-04 records subsequent lock revisions and the current runtime-board number. + **Prerequisites:** [algebraic effects and handlers](algebraic-effects-and-handlers.md), [DEC-03](../../../decision/DEC-03-purescript-faithful-type-system.md), [roles and coercions](../../frontend/type-system/prim.md), @@ -182,9 +183,11 @@ unrestricted safe runner. `Effect.Unsafe.unsafePerformEffect` unwraps Effect in its owning implementation and invokes this boundary. Its public type and unsafe status remain official. -`PSRS.Command.run :: Effect Unit -> Int` invokes it once and returns zero on +`PSRS.Command.run :: forall a. Effect a -> Int` invokes it once, discards its +result, and returns zero on normal completion. A trap propagates. The compiler uses the configured ordinary -runner and never identifies `Effect Unit` specially. +runner and never identifies `Effect` specially. Core checks an ordinary instance +of that runner at the selected entry's closed type, including non-Unit results. ST.run uses the same execution machinery behind its rank-N boundary. Its observable purity relies on the safe API permitting only isolated mutation; diff --git a/docs/design/backend/fp/open-records.md b/docs/design/backend/fp/open-records.md new file mode 100644 index 00000000..7625948c --- /dev/null +++ b/docs/design/backend/fp/open-records.md @@ -0,0 +1,159 @@ +# Open Record Rows and Dynamic Fields + +**Feature:** F-02 +**Status:** Draft +**Prerequisites:** [representation and evidence](representation-and-evidence.md), +[polymorphism and erasure](polymorphism-and-erasure.md), and +[CC IR](cc-ir.md). +**Summary:** A record whose row is abstract needs a layout that retains fields +outside its known prefix. The record owner supplies one immutable labelled +storage protocol for open rows and erased record values. Closed typed products +enter and leave that protocol through explicit recursive conversions. + +## Scope + +This contract covers record construction, projection, patterns, immutable +updates, row instantiation, bare polymorphic storage, nested aggregates, +callables, canonical ABI adaptation and checked record-storage primitives. +P5 owns row equality, `Lacks`, `Cons`, `RowToList` and dictionary evidence. +Runtime field selection does not solve those relations again. + +## Background + +An open row's known fields are only a prefix of its semantic contents. Treating +that prefix as a complete product loses the unknown tail. A product index +selected for one closed instantiation is not an index for every instantiation. +The original prelude's record dictionaries also use dynamic string keys through +`Record.Unsafe`; recognizing one reflected constant cannot implement that API. + +## Model + +The record owner registers one `RecordStorage` schema per representation table: + +```text +labels : Array String +values : Array Erased +record : Product [labels, values] +``` + +These are internal arrays, distinct from source-level array element typing. +The schema is immutable checked metadata. It names its three representations; +consumers do not search the table for physically similar products. Labels and +values have equal lengths, labels are unique and sorted by the target's +Unicode-scalar string ordering, and each value uses the existing recursive +erased-payload protocol. Runtime labels are field names, not source type IDs or +runtime type tags. Unknown tails retain their complete label/value contents. + +## Design + +Closed typed records retain their specialized product layouts. Open records +use `RecordStorage`. The canonical owner protocol for a record entering a bare +erased slot is also `RecordStorage`, so passing through `forall a. a -> a` +does not discard the metadata a later open-row use needs. + +Packing projects a closed product in canonical label order and erases each +field through the common payload planner. Unpacking recovers each required +field through that same planner. Recovery to a closed row validates its complete +label set; recovery to an open row retains the complete storage. Nested record, +array and callable fields use these plans recursively, including nominal ADT +payloads and generated ABI adapters. No nominal cast between a product and the +record store substitutes for packing or unpacking. + +Known-prefix field access and record patterns select by label and recover at +the checked field type. Immutable updates evaluate the base and all update +expressions exactly once in source order, then replace those fields in fresh +storage, retaining the unknown tail. No record operation mutates its input. + +## Algorithms + +Storage primitives expose bounded structural operations: field count, label at +an index, field read at its checked use type, and checked construction from +private labelled slots. The library owns searches, insert/delete algorithms and +the official `Record.Unsafe` wrappers. Heterogeneous private slots must retain +the producer's recursive erased policy; an arbitrary nominal `Any` cast cannot +stand in for the slot contract. Bounds, mismatched lengths and duplicate labels +fail explicitly. Missing keys cannot fabricate a value of arbitrary result +type. Invalid unsafe JS inputs involving `undefined` or object prototypes need +an explicit target contract and separate oracle observations. + +The runtime contract describes source operands, result types and the projection +to the shared `RecordOperation` owner. Before ABI erasure, a slot source type +must resolve through its checked type applications to a positively identified +ordinary data declaration with no constructors. Preserve the declaration's +identity and applied arguments; do not require a bare type node or infer the +carrier from its spelling. Absent type metadata, newtypes, opaque declarations +and constructor-bearing declarations do not qualify. P8 checks the source +contract and closed-record conversions, while P9 retains the provider export +identity and verifies its exact CC operation and physical signature. P9 trusts +P8's checked Core source scheme and cannot reconstruct a `Slot` declaration +identity from erased CC arrays. +The provider boundary reuses the application record schema and checked kernels; +it does not supply a second record codec. `build` copies both input arrays, +checks equal lengths, non-null labels and slots, and strict Unicode-scalar label +ordering, then returns fresh storage. Its `Array String` input still has the +source array's erased element representation, so labels are recovered as +strings before entering the owner's concrete `Array String` field. + +The generated provider namespace is `psrs:record-storage`, with exports and +checked source signatures `length :: forall r. Record r -> Int`, +`key_at :: forall r. Int -> Record r -> String`, +`payload_at :: forall r. Int -> Record r -> Slot`, and +`build :: forall r. Array String -> Array Slot -> Record r`. These declarations +are checked source bindings, not raw Wasm imports. P9 discharges each binding's +identity only after it matches the projected record operation, then lowers +through deduplicated application-local helpers over the registered +`RecordStorage` schema and codec. This keeps record construction and access +under the one application owner instead of introducing a second schema or +codec; it is an ownership choice, not a restriction on cross-module GC +references. + +## Code map + +`cc/record/` owns the checked schema and record plans. `cc/layout/` assigns +open-row shapes and registers the owner; `cc/payload` uses the owner at erased +boundaries. `cc/lower/record/` consumes source row evidence and emits explicit +operations. CC verification checks schema and plan endpoints, complete field +coverage, conversion direction and operand use shapes. MIR lowering realizes +these operations and verifies their physical layouts. `psrs-runtime` owns the +checked primitive contracts, while `psrs-stdlib` owns wrappers and algorithms. + +## Invariants and verification + +- An open row never becomes its known prefix's closed product. +- A record has one authoritative label/value sequence; no independent copied + typed payload can disagree with it after an update. +- Schema metadata validates referenced representations, nullability and array + elements. This proves physical schema, not arbitrary runtime array contents. +- Packing and checked dynamic construction establish contents invariants; + reads and recovery enforce required lengths, labels and bounds. +- Optimizations may remove conversions only when the same owner contract proves + them unnecessary. Finite local-row specialization is optional for correctness. +- WIT adapters retain logical closed field requirements while accessing the + producer's actual storage, including erased record fields in ADTs. + +## Worked example + +`read :: forall r. { value :: Int | r } -> Int` receives labelled storage. +Calls with `{ value: 42, label: "x" }` and `{ enabled: true, value: 42 }` +pack different closed products into that same storage shape. Selection uses +`"value"`, not a shared assumed index. Updating `value` preserves `label` or +`enabled`; returning the result through an erased slot retains those fields. + +## Boundaries and interfaces + +CST/HIR gain no backend metadata. Core preserves complete rows and checked +instantiations. CC owns storage and conversion requirements; MIR owns physical +GC types and operations. Foreign storage use types are checked before ABI +erasure. Missing schema or conversion evidence is an error. + +## Open questions and future work + +Implementation acceptance is tracked in the [execution checklist](../../../implementation/backend/open-records.md). +The existing closed-product-only implementation remains incomplete until every +required vertical slice, malformed-IR rejection and runtime comparison passes. + +## References + +- [DEC-17](../../../decision/DEC-17-representation-and-evidence.md) +- Original prelude v6.0.1 `src/Record/Unsafe.{purs,js}`, pinned at + `f4cad0ae8106185c9ab407f43cf9abf05c256af4`. diff --git a/docs/design/backend/fp/pattern-matching.md b/docs/design/backend/fp/pattern-matching.md index cf2ec8c7..372bd304 100644 --- a/docs/design/backend/fp/pattern-matching.md +++ b/docs/design/backend/fp/pattern-matching.md @@ -213,8 +213,15 @@ Witness construction is a byproduct of the usefulness recursion: when a subproblem has no covering row, the algorithm returns a pattern that the existing rows cannot match. Non-exhaustive matches and redundant rows are frontend diagnostics; the compiler never silently emits a partial decision. -Coverage is checked where the pattern matrix is built, so a rejected program -keeps its source span. +The shared `psrs-coverage` engine consumes stage-owned type facts. Type checking +uses it after checking alternatives to issue a report-only `Partial` obligation +with a missing-pattern witness. Inferred declarations may retain this +constraint; declared signatures must provide it or report `NoInstanceFound` at +P5. The existing dictionary elaboration convention is preserved consistently +for both inferred and declared constraints; `Partial` exposes no methods. +Authorized cases carry a generated `PartialFallback` trap through THIR/Core. +CC uses the same analysis for decision coverage and redundancy without +reconstructing source authorization. Diagnostics keep the case's source span. For an irrefutable query over a finite signature, check the default matrix first when the matrix does not observe every constructor, then construct a finite @@ -418,19 +425,26 @@ outer value is already the tag, the target is a MIR `Switch` rather than a chain ## Code map -The pattern stage has three owners: the decision compiler in `cc/case/`, which -builds and analyzes the decision DAG; `cc/lower/`, which realizes a decision as -CC assignments; and the MIR/Wasm side, which turns a tag dispatch into a -`Switch` terminator and then a `br_table`. Coverage and redundancy diagnostics -are produced by the decision compiler, not by the lowering. +The shared `psrs-coverage` crate owns usefulness, specialization, and witness +reconstruction. The type checker projects resolved pattern types and issues +`Partial` obligations; `cc/case/coverage/` projects verified Core types and +reports backend coverage or redundancy. The decision compiler in `cc/case/` +builds the DAG; its realizer emits CC assignments. MIR/Wasm turns tag dispatch +into a `Switch` terminator and then a `br_table`. ```text +psrs-coverage/src/ + engine/ # shared usefulness and specialization + analyze.rs # exhaustiveness, redundancy, and witness report + render.rs # missing-pattern rendering +psrs-typecheck/src/typecheck/infer/ + coverage.rs # resolved checker-type adapter + case.rs # Partial wanted and authorized trap fallback cc/ case/ mod.rs # lower_case entry, coverage, and DAG dispatch coverage/ - mod.rs # source-branch provenance and diagnostic report - engine.rs # shared usefulness, specialization, witnesses + mod.rs # Core type adapter and backend diagnostic report decision/ mod.rs # SurfacePattern, Decision, Test, Action, ColumnKey compile/ # matrix construction, specialization, sharing @@ -468,9 +482,10 @@ Required types and entry points: } ``` -- `cc/case/coverage/` provides shared usefulness, exhaustiveness and - redundancy analysis, and witness reconstruction. It reports a non-exhaustive - match or a redundant row with the scrutinee or alternative span. +- `psrs-coverage` provides usefulness, exhaustiveness, redundancy, and witnesses + through the `Types` interface. Each adapter resolves every nested pattern + type after branch unification before analysis. `cc/case/coverage/` reports a + non-exhaustive verified-Core match or a redundant row with its source span. - `cc/case/decision/realize/` realizes a decision into CC assignments exactly once per scrutinee, in first-match order: @@ -622,10 +637,11 @@ only one that projects, and it does so once. ## Implementation notes -`cc/case/coverage/` now runs cycle-safe usefulness analysis over constructor -and record patterns, including nested fields and recursive data types. It -rejects non-exhaustive cases before CC lowering and includes a missing -constructor pattern in the diagnostic. It also emits one source-spanned P8 +`psrs-coverage` runs cycle-safe usefulness analysis over constructor and record +patterns, including nested fields and recursive data types. The checker issues +witness-bearing `Partial` obligations before Core lowering; annotated values +without authorization reject at P5. The CC adapter independently checks its +verified-Core input and emits one source-spanned P8 warning for each redundant row. The driver exposes warnings on compiled artifacts with their source index, and the CLI prints them for `build` and `wat` commands. @@ -663,7 +679,7 @@ future work as described above. The regression suite exercises the mapped stage boundaries: coverage unit tests check useful/uncovered witnesses and redundant rows; the driver regression -`reports_a_missing_nested_constructor_as_a_coverage_witness` checks the P8 +`reports_a_missing_nested_constructor_as_a_coverage_witness` checks the P5 diagnostic, nested witness, and exact case-expression span; and `exposes_redundant_case_alternatives_as_source_spanned_warnings` checks the offending alternative range. Decision-compiler tests preserve the first branch diff --git a/docs/design/backend/fp/polymorphism-and-erasure.md b/docs/design/backend/fp/polymorphism-and-erasure.md index 10cfffee..e8d94127 100644 --- a/docs/design/backend/fp/polymorphism-and-erasure.md +++ b/docs/design/backend/fp/polymorphism-and-erasure.md @@ -273,8 +273,14 @@ expression can build one, so its only values come from unchecked coercions. It therefore uses the same erased ABI as a bare abstract type, whether it is used bare (`Void`) or applied (`VariantF r a`). This is not the resource-handle case: a nullary `foreign import data` type is opaque and stays a handle, and an -applied foreign type already uses the erased reference. Inhabited algebraic -types keep their enum, aggregate, or newtype layout. +applied foreign type already uses the erased reference. + +A transparent newtype chain that returns to a declaration already visited +also has no finite values (for example, `newtype Void = Void Void`). Its +canonical storage is the same erased reference; layout and conversion stop at +that boundary instead of recursively expanding the cycle. This is detected by +declaration identity, including mutual cycles. Inhabited algebraic types keep +their enum, aggregate, or newtype layout. This is viable because: @@ -622,29 +628,28 @@ adapter is invoked, and each adapter call unboxes its argument exactly once. reach the ABI boundary; ABI adaptation happens on concrete canonical signatures only. -### Closed local row instantiations +### Generic local row closures -Before CC layout, checked nonrecursive local lambdas with row quantifiers may -be expanded at finite closed uses. The Core checking relation owns each row -solution, including residual fields with no existing type-arena node. Explicit -materialization appends row nodes without changing the source arena's existing -identities; capture-avoiding substitution discharges the scheme's quantifiers -and freshens local identities. This preparation is independent of optimization -budgets and preserves evaluation of captured computations. +CC accepts checked local row-polymorphic lambdas directly, including captured, +recursive and higher-order uses. The [open-row store](open-records.md) supplies +the generic representation; closed arguments cross its explicit pack/unpack +boundaries at their checked use types. Two closed instantiations can call the +same captured closure without materializing separate local bindings. -An unresolved use, recursive binding, or scheme with additional unresolved -quantifiers retains its full source scheme. Open runtime rows remain an explicit -unsupported layout; this preparation does not define an open-row ABI or narrow -the source model. Input and output Core verification remain mandatory. +P8 does not run a finite local-row specialization preparation. Ordinary Core +optimization may specialize declarations, but its budget and the availability +of closed uses cannot determine correctness. Input Core verification remains +mandatory before layout; closure conversion and its verifier own the resulting +generic call and capture contracts. ## Open questions and future work - **Dictionaries end to end.** Type-class elaboration must produce the dictionary values this design assumes and feed them through the normal aggregate path. -- **Open-row aggregates.** Canonical generic arrays and closed records use the +- **Open-row aggregates.** Canonical generic arrays and records use the conversion contract in [representation and evidence](representation-and-evidence.md). - Open-row records still need a separate representation and conversion contract. + Complete the [open-record execution checklist](../../../implementation/backend/open-records.md). - **Higher-order acceptance breadth.** Direct generic calls, concrete arguments to generic parameters, and returned polymorphic functions are the remaining adapter cases to exercise end to end. diff --git a/docs/design/backend/fp/representation-and-evidence.md b/docs/design/backend/fp/representation-and-evidence.md index a039a75f..8b4b6dba 100644 --- a/docs/design/backend/fp/representation-and-evidence.md +++ b/docs/design/backend/fp/representation-and-evidence.md @@ -190,18 +190,21 @@ Equal keys intern to one representation; P9 assigns one `DefinedTypeId` per reachable representation and does not merge by physical shape. This preserves [DEC-07](../../../decision/DEC-07-runtime-representation-for-parameterized-adts.md)'s single erased layout for a parameterized data type. Open rows have no canonical -product and remain unsupported. +product; their labelled store is defined in [open record rows](open-records.md). +The [execution checklist](../../../implementation/backend/open-records.md) +records the remaining implementation obligations. ### Aggregates in bare polymorphic slots The Array owner uses erased-element storage from initial construction. Entering a bare variable or abstract Array constructor slot retains that same array. -The closed-record -owner normalizes a record to a product with the same ordered logical labels -and one non-null erased field per label. These protocols apply recursively to +The record owner normalizes a record entering a bare slot to its registered +labelled store: parallel ordered String labels and non-null erased payloads. +Closed typed products pack into that store and recover through explicit plans +with complete label validation. These protocols apply recursively to nested arrays, records, and callable fields. Recovery first reads the owner's canonical storage. Array element reads recover the checked use representation; -record recovery converts its fields. Array transport itself does not reconstruct +record recovery checks its row and converts its fields. Array transport itself does not reconstruct storage. A cast between distinct private buffer or record layouts cannot establish their conversion contract. @@ -550,8 +553,8 @@ existing array reference without copying its elements. - **Monomorphization.** A specialization pass may remove boxes and casts at statically known instantiations while preserving the erased representation as the fallback; it must not become a second correctness mechanism. -- **Open rows.** Open-row records still need a canonical policy and conversion - contract before they join the registry. +- **Open rows.** Complete the [labelled-store execution checklist](../../../implementation/backend/open-records.md), + including updates, patterns, ordinary target primitives and library conformance. ## Implementation notes diff --git a/docs/design/backend/wasm/linking-and-runtime.md b/docs/design/backend/wasm/linking-and-runtime.md index e424011e..3b7bfdc2 100644 --- a/docs/design/backend/wasm/linking-and-runtime.md +++ b/docs/design/backend/wasm/linking-and-runtime.md @@ -661,6 +661,17 @@ and higher-order uses follow the ordinary closure adapter path. This is a typed external-call ABI, with no State token, dependency chain or read/write ordering analysis. It does not define an Effect/ST implementation. +The source bindings under `psrs:record-storage#length`, `#key_at`, +`#payload_at` and `#build` have a different implementation contract. They are +checked source declarations projected by P8/CC onto the application's single +`RecordStorage` owner; they do not become raw Wasm imports or use the separate +`psrs:runtime-storage` encoded module. P9 validates the retained provider +identity against its record operation, then realizes the operation through +deduplicated application-local helpers using that owner's schema and codec. +This avoids a second record representation and codec while keeping the raw +GC-array ABI above unchanged. The choice follows record-schema ownership, not +a restriction on passing GC references between modules. + The exhaustive [intrinsic implementation catalog](../fp/intrinsic-implementations.md) owns direct/generated/artifact selection. HIR owns language schemes and semantic effects; the runtime owns raw export contracts and transport protocols. CC keeps diff --git a/docs/design/frontend/type-system/type-inference.md b/docs/design/frontend/type-system/type-inference.md index 6dc6eef3..50a979fc 100644 --- a/docs/design/frontend/type-system/type-inference.md +++ b/docs/design/frontend/type-system/type-inference.md @@ -110,6 +110,16 @@ polymorphic expression before solving the unknown, following the official checker's rule. Explicit polymorphic fields and annotations supply the boundaries at which a polymorphic value may be retained. +When checking an application against a monomorphic result context, constrain +the callable's result before checking its argument. This keeps a known result +such as `Int` concrete while a rank-N callback opens a skolem scope, so a rigid +mismatch reports `TypesDoNotUnify` rather than a premature `EscapedSkolem`. +Instantiate the result with the ordinary scheme operation and apply any result +dictionary evidence to the actual application. Contexts containing nested +universal or qualified types retain synthesis followed by subsumption: pushing +them into fresh constructor parameters would admit impredicative inference. +The common unknown-binding escape check remains authoritative. + Core replays those checked explicit instantiations, including universal types used as nominal constructor arguments. Its shared type matcher binds only the declaration's flexible parameters and retains a replacement's complete `ForAll` @@ -127,6 +137,10 @@ check(expr, expected): if expected begins with constraint: bind given evidence, check body if expr is lambda and expected is arrow: bind parameter, check result if expr is record: check each field against its expected field type + if expr is application and expected is monomorphic: + synthesize callable; instantiate its leading quantifiers/constraints + instantiate and subsume its result against expected; retain evidence + check argument against parameter; apply result evidence to application otherwise synthesize expr and subsume synthesized type against expected infer_application(function, argument): diff --git a/docs/feature/F-02-portable-programs.md b/docs/feature/F-02-portable-programs.md index 77f20559..d5da9b84 100644 --- a/docs/feature/F-02-portable-programs.md +++ b/docs/feature/F-02-portable-programs.md @@ -127,8 +127,14 @@ returned functions. Each use can instantiate a quantified value independently; nested constraints are supplied through the corresponding class instances. Generic arrays and records cross the supported polymorphic boundaries with their contents -preserved. Open rows and unsupported WIT shapes receive source-oriented -diagnostics. +preserved. Open record rows support field access, record patterns, and immutable +updates while preserving extra fields. Unsupported WIT shapes receive +source-oriented diagnostics. + +A non-exhaustive pattern match requires `Partial`. An inferred declaration may +retain that constraint; an explicit signature must provide it or checking +reports a missing case at the original source range. `unsafePartial` permits +calling the partial value, and an unmatched input traps if that path executes. Role annotations describe whether a type parameter is nominal, representational, or phantom. Inferred roles follow the type's fields, including diff --git a/docs/implementation/backend/open-records-handoff-2026-10-17.md b/docs/implementation/backend/open-records-handoff-2026-10-17.md new file mode 100644 index 00000000..ee3cfd13 --- /dev/null +++ b/docs/implementation/backend/open-records-handoff-2026-10-17.md @@ -0,0 +1,1004 @@ +# Open Records and Roadmap Handoff — 2026-10-17 + +**Latest handoff update: 2026-10-18.** This opening snapshot supersedes every +historical clean/dirty checkout statement below. The implementation and its +matching tests/design/acceptance documents are committed on local `master`: + +1. `e5972241` — Share pattern coverage between type checking and closure conversion. +2. `e331f9ba` — Lower generic local rows without mandatory specialization. + +The implementation checkpoint is `e331f9ba9a785f24496d37d0a1b0d92e8a1c0ca6`. +The handoff update is the documentation-only commit immediately following that +checkpoint; use `git log -3 --oneline` to identify the final handoff HEAD. After +that commit the compiler checkout is clean, with no uncommitted implementation +or test changes. Nothing was pushed; no PR, issue or project-board action was +performed. Implementation stops here for the receiving agent. + +**Acceptance status:** The open-record topic remains **In Progress**. Core +storage, operations, generic closure execution, library wrappers and nested ABI +have execution evidence; the complete requirement-by-requirement integrated +acceptance review is still outstanding. Do not describe this as completed open +records or infer topic completion from the compiler-wide runtime count. + +## Receiving agent start point + +(Completed on 2026-10-18 — see the [outcome addendum](#receiving-agent-outcome-2026-10-18) +at the end of this handoff. The numbered plan below is preserved for the record.) + +1. Inspect `git status --short` and `git log -3 --oneline`; preserve any newer + changes made after this snapshot. Read the normative + [open-record design](../../design/backend/fp/open-records.md), + [DEC-22](../../decision/DEC-22-labelled-record-storage.md) and + [OR-01–OR-14 checklist](open-records.md). +2. Audit OR-01–OR-12 as one contract, mapping each requirement to its actual + producer, verifier, direct-IR tests and executed runtime evidence. Resolve + OR-04/OR-05's remaining combination-coverage statements: inspect nested + aggregate/callable transport and record-pattern scope interactions, add + minimal regressions for concrete gaps, and repair their common owner if + they fail. Do not replace those statements with an unsubstantiated Done. +3. OR-07's forced-specialization prerequisite has been removed. Preserve its + unoptimized-Core single-reader assertion and negative-control evidence. + OR-10/OR-11 now cover each read/update operation; OR-12 checks actual nested + ABI values in both directions. Their detailed evidence is in the final + "Generic local rows and nested ABI follow-through" section below. +4. Complete OR-13/OR-14 only after that integrated audit, relevant gap fixes and + current-tree validation. Keep design/code/tests together by topic, remeasure + affected scoreboards, and update D-04 from measured results. No current + focused test failure is known. BigFunction's resource bound and the missing + `Test.Assert.checkThrows` target behavior remain explicit compiler-wide + limitations, without fabricated foreign implementations. + +## Current package and validation snapshot + +- Compiler repository: `/Users/biu/Projects/purescript-rs`, local `master`, + implementation checkpoint `e331f9ba` plus the following handoff-only commit. +- Development package: `/tmp/psrs-open-record-stdlib-or09`, clean at + `e556bf2f8cb377c049d2ff0a5a97b484c7b36bc5`, source fingerprint + `fnv1a64-v1:7d96f1c44d8d52cc`. Its local commit has not been pushed. +- The compiler lock retains `../psrs-stdlib` with that revision. Set + `PSRS_STDLIB_ROOT=/tmp/psrs-open-record-stdlib-or09` for package-consuming + commands. The shared sibling `/Users/biu/Projects/psrs-stdlib` is separately + clean at `1e01310daa7e56de5ed379029d6a24dcf39e879c`; it was not modified. +- The implementation tree now committed above passed full workspace tests: + **2,100 passed / 0 failed / 5 ignored**, with required Wasmtime 49.0.3 and + the selected development package (`/tmp/psrs-or07-workspace.log`). Formatting, + source layout, strict workspace Clippy and diff whitespace checks pass + (`/tmp/psrs-or07-fmt.log`, `/tmp/psrs-or07-clippy.log`). Committing and editing + this handoff did not change tested source; the long checks were not rerun + solely for commit metadata or this documentation update. +- All five ignored scoreboard groups were exercised separately. Frontend: + L1 904/908, L2 72/72 (passing resolution 403/413), L3 39/48, L4 39/50, + L5 81/97 (`NoInstanceFound` 52/53), `/tmp/psrs-or07-frontboards.log`. +- Runtime: **287/340**, unchanged success set, 365 roots, 74 support inputs, + 25 FFI groups excluded. The 53 non-agreeing roots split into 31 P5 typecheck, + 11 P5 kind check, 2 P8, 5 P3 resolve, 2 P0 lex, one runtime timeout and + BigFunction. BigFunction stops at the 2 GiB RSS guard in 27.89 seconds with + observed peak 2,141,824 KiB; no compile acceptance or performance repair is + claimed. Evidence: `/tmp/psrs-or07-runtime/summary.json`, `results.jsonl` and + `big-function-checked.json`. +- Release suite: `/tmp/psrs-or07-suite`, SHA-256 + `52a9b77e706fd2030ed8c235c75f3a313ee118a18de0b49e4be21eb375e03df1`. +- Library-owned Node conformance passes 13/13 + (`/tmp/psrs-or07-node-conformance.log`). The current debug CLI's independent + `Record.Unsafe` JS/Wasm oracle accepts the value program and all three + expected-trap programs (`/tmp/psrs-or07-record-oracle/record-unsafe.json`). + The package remains unchanged. These `/tmp` artifacts are local evidence; + regenerate them if they are no longer available to the receiving agent. + +## Historical implementation and checkpoint notes + +The sections below retain the earlier investigation and exact evidence. Their +branch, HEAD, clean/dirty and validation statements describe their own snapshots; +the opening 2026-10-18 snapshot is authoritative for resumption. + +## Committed state (2026-10-17, evening) + +The work was committed in five topical commits on `master`, in order: + +1. `f712b8e7` Run the configured library runner and measure programs as groups +2. `b9148f59` Check rank-N arguments against a monomorphic contextual result +3. `3c31db7e` Give open rows one immutable labelled record store +4. `48704baf` Select open-row record patterns by label +5. `b7778658` Update open rows through one immutable labelled store + +`stdlib.lock.json` pins the same revision +`6fcef6ccbab1f3459af2573bbef4ae25b86852d4` as at the pause; the shared sibling +`psrs-stdlib` checkout remains at its earlier unrelated state, unchanged. + +## Checkout and package ownership + +- Compiler: `/Users/biu/Projects/purescript-rs`, branch `master`, current HEAD + `b7778658` (at the original pause it was + `fe6dd78f14d5f4913e78d2d30c57e3b38cc5c900`). +- The compiler work this document describes was uncommitted at the pause + (including new files and + module-directory moves) and is preserved in the committed state above. +- Use `PSRS_STDLIB_ROOT=/tmp/psrs-symbol-reification-stdlib` for every compiler + command consuming the package. This isolated package is clean at + `6fcef6ccbab1f3459af2573bbef4ae25b86852d4`, fingerprint + `fnv1a64-v1:5fcbb57525c63eb1`; `stdlib.lock.json` pins that revision. +- The shared sibling `/Users/biu/Projects/psrs-stdlib` is instead at + `1e01310daa7e56de5ed379029d6a24dcf39e879c`. Preserve it; do not switch it or + change the lock merely to make validation run. Its state was changed outside + this work. +- The isolated package has local command-runner and Symbol adapter commits; + they have not been pushed. Preserve the package directory as part of handoff. + +A local recovery snapshot is in +`/tmp/psrs-open-record-handoff-2026-10-17/`: `tracked.patch`, +`untracked.tar.gz`, `status.txt`, and `source-sha256.json`. It was captured +before adding this handoff document. These are recovery artifacts, not a +request to reset or reapply changes in the already populated checkout. + +## Earlier slices already validated + +These changes share the dirty tree and must not be discarded while repairing +open records: + +1. The runtime scoreboard groups each program root with its support directory, + following reference `purs`. The inventory is 439 sources: 365 roots plus 74 + support inputs. Twenty-five FFI groups are excluded, leaving 340 programs. +2. Configured executable normalization uses checked polymorphic instantiation + through Core's common `Module::checked_instantiation`. The library-owned + `PSRS.Command.run :: forall a. Effect a -> Int` runs the action exactly once + and discards its result. Unchanged official `Sequence.purs` prints `Done` + and exits zero. See `polymorphic-command-runner.md` in this directory. +3. Symbol reification preserves official pure `Data.Symbol` definitions and + changes the private target foreign boundary to a typed `Unsafe.Coerce` + delegate. Package conformance compares actual `purs`/Node results, callback + behavior, strictness, strings and rejection cases. +4. Contextual application checking propagates a monomorphic expected result + before checking a rank-N argument. Polymorphic/constrained contexts retain + synthesis and subsumption; the common skolem guard remains intact. Result + dictionaries survive `instantiate_use`/`apply_constraints`. See + `../frontend/contextual-application-results.md`. + +The earlier integrated workspace run passed **2,037 tests**, zero failures, +five ignored scoreboard tests; required scoreboards were run separately. +Evidence: `/tmp/psrs-symbol-context-workspace-final.log`. The earlier runtime +snapshot was **273/340**, all successful cases exiting zero, with 67 remaining. +Its manifest and decomposition are +`/tmp/psrs-symbol-runtime-acceptance/summary.json`. This is a baseline, not +evidence that the subsequent open-record changes are green. + +## Current architecture and ownership + +Read these together before changing the representation: + +- Design: `docs/design/backend/fp/open-records.md` (Draft). +- Decision: `docs/decision/DEC-22-labelled-record-storage.md` (Proposed). +- Checklist: `docs/implementation/backend/open-records.md`, OR-01–OR-14. +- Existing normative representation, erasure and control contracts. + +Closed typed records retain specialized products. Open rows and all bare erased +record values use one immutable labelled store: a product header containing +parallel `Array String` keys and `Array Erased` payloads. Keys are sorted and +unique; producers establish runtime contents. Schema verification proves the +physical schema, not arbitrary array contents. Complete closed recovery checks +lengths and every expected key before recovering fields. No source type IDs or +module-name tests become runtime record identities. + +Implementation map: + +| Owner | Files and responsibilities | +| --- | --- | +| CC schema | `cc/record/`: immutable `RecordStorage`, schema validation, structural `RecordOperation` operand/result shapes | +| Layout | `cc/layout/{aggregate,scalar,protocols}.rs`: shared open-row header; closed products; canonical erased arrays | +| Payload | `cc/payload.rs`: recursive common pack/unpack, array and callable transport; `stored_guest_layout` retains original logical fields | +| Conversion | `cc/convert.rs`, `cc/lower/conversion/record.rs`: explicit `RecordPack`/`RecordUnpack`, closed/open boundaries | +| Source access | `cc/lower/record/open.rs`: find known field by literal label, read erased slot, recover through common payload policy | +| CC rejection | `cc/verify/ops/{record,products}.rs`, `aggregate/record.rs`: schema, endpoints, labels, nested conversion checks | +| MIR | `mir/lower/aggregate/{fields.rs,record/}`: physical-reference persistence, pack/unpack, checked reads; common ProductMap field transforms | +| Helper ownership | `mir/lower/conversion_helpers/`, `mir/lower/record_call.rs`: deduplicated conversion/read helpers and symbol allocation | +| ABI | `GuestLayout::RecordStorage`, `mir/wit/`, Wasm list `record/storage/`: retain both logical record and physical store, recurse through full Field descriptors | +| MIR rejection | `mir/verify/records.rs`: registered owner, GC array/header definitions, logical fields/labels and root hints | + +`cc/assignment.rs` was extracted from `cc/mod.rs` to respect the 500-line rule. +The old single-file conversion helper, Wasm list record and driver record tests +were moved into directories with `mod.rs`; deletions in git status are these +moves, not removals of coverage. Source-layout rules require that grouping. + +## Validation before the last repair + +- All **518 backend tests** passed in the failing workspace run; the full + workspace did not pass. Focused malformed-IR coverage includes eight schema, + six conversion-plan and five MIR owner/physical mutations. +- Eight new required-Wasmtime source tests in `tests::records::open_rows` + exited 42: different row indices, recursive access, complete closed recovery, + erased ADT and Array slots, independent nested rows, callable fields and + callable closed-record results converted to open rows. +- Direct IR round trip with a String preceding nested ArrayMap exits 85. + Incorrect keys and short payload arrays trap in actual Wasmtime execution. +- Record/string/flags list fixtures encode and validate both ABI directions. + Three filesystem programs execute again after ABI views retained their + complete logical field descriptors. +- Rank-N tests, source-layout check, formatting and Clippy passed before the + last control repair. The current tree must be rechecked after that repair. +- Public Symbol conformance rerun with the pre-repair debug CLI was accepted: + `/tmp/psrs-record-read-symbol-oracle/symbol.json`. + +Useful focused evidence: +`/tmp/psrs-record-composition-tests.log`, +`/tmp/psrs-record-checkpoint-tests.log`, +`/tmp/psrs-record-abi-storage-tests.log`, +`/tmp/psrs-record-filesystem-owner-verified.log`, +`/tmp/psrs-open-record-rank-n.log`, +`/tmp/psrs-record-module-layout.log`, +`/tmp/psrs-record-read-clippy-final.log`. + +## Exact full-workspace failure and last repair + +`/tmp/psrs-record-read-workspace-final.log` finished with exit 101. The driver +library section had **786 passed, 7 failed**. The seven failures were: + +1. `tests::control_costs::control_ancestry_probe_observes_independent_non_tail_extents` +2. `tests::control_costs::control_tail_handlers_bound_extent_ancestry_and_post_gc_live_heap` +3. `tests::control_costs::replay::queue_shape_changes_traversal_with_equal_callbacks_requests_and_ancestry` +4. `tests::control_costs::replay::saved_callback_length_changes_actual_loop_work_at_fixed_requests_and_ancestry` +5. `tests::control_costs::replay_invocations_grow_with_requests_at_fixed_ancestry` +6. `tests::control_regressions::root::sequential_and_exclusive_runs_preserve_independent_invocations_after_optimization` +7. `tests::records::typechecks_an_open_record_row_by_label` + +The first six were actual Wasmtime **cast failures**, not changed cost +thresholds. Do not weaken runtime or measurement assertions. The last test +still expected open-row compilation to be unsupported even though its reachable +accessor now executes. It was changed to run and require exit 1; that changed +test has not yet been rerun. + +The last bounded repair bridges the machine's private two-field result product +and the new public erased record protocol: + +- `cc/lower/control/pair.rs` obtains exact erase/recover plans from the common + payload planner; no separate record codec is introduced. +- `Machine::pair` packs before publishing an erased result; + `Machine::pair_field` recovers the canonical store before projecting the + private product. The machine pair representation itself stays a product. +- `plan/boundary/{ops,primitives}.rs` checks the additional conversion at its + exact ordered location and checks the complete expected plan. It does not + silently strip arbitrary conversions as aliases. +- `cc/layout/control/mod.rs` ensures the record schema exists for machine + pairs, including a machine without live source records. Its array-layout + unit expectation was changed from two arrays to three, reflecting labels. +- Fixed `RecordPack` whose fields are all `Identity` now emits straight-line + code instead of a conversion helper. This applies generally, not by control + symbol. It avoids introducing a helper tail target into a machine terminal + whose exact targets are checked by MIR. Packing with actual field conversions + and unpacking still use helpers to isolate internal control flow. + +An intermediate attempt failed the CC construction check because the producer +and checker edits were incomplete; that was corrected. The next attempt was +rejected by MIR's exact terminal-target check after helper tail-call rewriting; +the straight-line pack rule above addressed that case. The final focused run +**passed** the independent-invocation regression (both source forms): +`/tmp/psrs-record-control-pair-fix.log`, one passed, 792 filtered out. + +This is the **only confirmed post-repair runtime result**. The five cost tests, +changed old record test, backend unit expectation, full workspace, Clippy and +scoreboards have not been rerun after the repair. Review the general helper +selection rule and both construction/verifier boundaries before accepting it. +No temporary checker debug printing remains. + +## Fresh scoreboard snapshot, before the control repair + +The two release cohorts finished successfully as scoreboard measurements: + +- `/tmp/psrs-record-read-runtime-0.log`: 137/170, 13 excluded. +- `/tmp/psrs-record-read-runtime-1.log`: 139/169, 12 excluded. +- `BigFunction.purs` was separately stopped above 2 GiB RSS: peak 2,101,360 KiB, + 22.27 seconds, signal 9, before a compiler diagnostic. + +Combined: **276/340**, 25 FFI groups excluded, 64 remaining. Relative to the +273 baseline, `1807.purs`, `NestedRecordUpdate.purs` and `ObjectGetter.purs` +newly pass, with no newly failing program roots in these snapshots. Remaining: +31 typecheck, 11 kind check, 10 closure conversion, 5 resolve, 2 lex (DEC-16), +3 foreign-library linking, one 10-second runtime timeout, one compiler resource +bound. `Deriving` and `TCOFloated` still need `Record.Unsafe.unsafeGet`; +`TCOMutRec` still reaches unsupported `Test.Assert.checkThrows`. + +The release suite binary SHA256 was +`927caf748a852d6cff0b888700c876ba77986ae9cf9719cff124275e1dd578ae`. +The package was `6fcef6cc`. A parsed comparison is saved as +`/tmp/psrs-open-record-handoff-2026-10-17/runtime-pre-pair-fix-summary.json`. +It deliberately labels this a pre-repair measurement. Do not reuse its binary +as evidence for current source. + +`/tmp/psrs-record-read-frontend-boards.log` records four passing board test +invocations: parse agreement includes 410/413 passing files with 26 exclusions; +resolution 72/72 failing and 403/413 passing; kind 39/48; type 39/50; +row/typeclass 77/97. The earlier full parse total was 904/908; inspect the +new log's complete decomposition before updating a total. + +D-04 and README still state the **273/340 earlier snapshot**. They have not +been promoted to this new checkpoint because its full regression was red and +the final repair needs validation. Update measurements with exact code/package +identity after rerunning the relevant gates; do not call 276 full acceptance. + +## First actions for the next agent + +1. Inspect current status and this handoff; preserve all dirty work and use the + isolated package. Review the control pair repair against the common erasure + and checked-plan contracts. +2. Run the five cost tests, existing control regressions and the changed old + open-row test with required Wasmtime. Diagnose any remaining failures before + extending the feature. Do not broaden casts or relax assertions to go green. +3. Run backend tests (including the updated control array owner expectation), + source-layout checks, full workspace, formatting and Clippy. Only then + describe this read/transport checkpoint as integrated. +4. Rebuild and rerun affected scoreboards for the final source, maintain exact + measurement identity, and update D-04/checklist/README proportionally. +5. Continue the outstanding open-record vertical slices below. Keep the topic + explicitly incomplete until all required execution and rejection evidence + exists. + +Representative commands, run from the compiler checkout: + +```sh +export PSRS_STDLIB_ROOT=/tmp/psrs-symbol-reification-stdlib +export PSRS_REQUIRE_WASMTIME=1 +cargo test -p psrs-driver --lib tests::control_costs +cargo test -p psrs-driver --lib tests::control_regressions +cargo test -p psrs-driver --lib tests::records +cargo test -p psrs-backend +cargo fmt --all --check +cargo test --workspace +cargo clippy --workspace --all-targets -- -D warnings +PSRS_ORACLE=annotations cargo test -p psrs-driver --test suite -- --ignored --nocapture +``` + +Consult the current harness filters and resource guard before launching a full +runtime board; `BigFunction` is a known memory risk, not a free compiler pass. +The historical timeout case must remain a separately reported non-agreement. + +## Required work still open + +- Open-row record patterns: use label projection, not product-index guesses. +- Immutable and type-changing updates: preserve unknown tails, evaluate base + and update expressions exactly once in source order. Existing pattern and + update paths still assume product labels in places. +- Runtime coverage of structural Length/LabelAt and invalid read boundaries. +- Checked small runtime storage bindings and library-owned public + `Record.Unsafe` get/has/set/delete wrappers; official source fidelity and + actual upstream JavaScript differential conformance. +- Empty, Unicode, reserved-looking and missing keys; explicit target semantics + for JS undefined/prototype behavior that Wasm cannot represent. +- Imported/scoped composition, broader nested ABI execution, malformed plans + for remaining operations, official Deriving/TCOFloated and full gates. + +No `Record.Unsafe` target implementation was added to the isolated package. +Reached foreign values still fail honestly. Do not replace them with recursion, +fabricated results or compiler-owned whole-library algorithms. + +## Next binding direction investigated, not implemented + +The small checked GC storage-provider boundary is in +`psrs-runtime/src/abi/storage/` and `psrs-runtime/src/storage/`, projected by +backend `bindings/runtime/` and `mir/lower/storage_call.rs`. It currently owns +array fill/read/write/trap and validates source types before raw ABI erasure. +Read the source contract before adding record roles; do not misuse its `Any` +trap-result category for arbitrary polymorphic record payloads. + +An ordinary private constructorless `data Slot`, carried through existing +`Unsafe.Coerce`, successfully transported heterogeneous values in an Array: +Int 17, Unicode String, and a record whose recovered value was 42. Probe: +`/tmp/psrs-record-boundary/ErasedCarrier.purs`; actual runner evidence: +`/tmp/psrs-record-boundary/ErasedCarrier-run/run.json`. This suggests an explicit +erased payload carrier can use existing common representation semantics. It +does not establish a finished primitive contract or authorize arbitrary nominal +casts. Validate a semantic constructorless carrier and its actual erased shape, +not its module or textual name. + +Possible small roles are record length, key-at, payload-at and checked building +from parallel arrays. Library searches/copy/update algorithms should stay in +`psrs-stdlib`. `PSRS.Ordering` and `PSRS.Array.Classes` offer private low-level +building blocks without importing Prelude/Ord back into `Record.Unsafe`; check +dependency cycles explicitly. Fresh-array write ownership, sorted unique keys, +nonnull fields and equal lengths need real validation. This remains design +exploration, not an accepted implementation plan. + +## Roadmap state + +Last read: Phase 3 #94 In Progress/0 corpus cases, #95 Blocked/1, #96 +unstarted/0, #124 In Progress/0; Phase 4 #99 has 61 cases. #124's zero reflects +removing false standalone support-module blockers, not completion of all stdlib +APIs. #95's remaining `checkThrows` needs a real target exception-recovery +contract. Neither issue was closed and no comments were posted for this slice. +Refresh the board before selecting further roadmap work; local snapshots may +be stale. The broader project has meaningful work and is not globally blocked. + +## Resumption progress, 2026-10-17 (later the same day) + +First actions 1–4 are done, and action 5's first vertical slice is landed. +The control-pair repair was reviewed against the common planner and checked-plan +contracts and holds. With required Wasmtime the five cost tests, all 32 control +regressions and the record tests pass; backend tests, the full workspace, +formatting, source layout and Clippy pass (2,053 passed at the read/transport +checkpoint). The runtime board was remeasured: 276/340 at that checkpoint with +release suite `61d4dea7488511191630dcf2adb060f2de9d62701786893434b6b6f955212b7e`; +D-04, README and the runtime scoreboard record were promoted, and +[the open-record checklist](open-records.md) gained a post-repair checkpoint +section. Evidence lives under `/tmp/psrs-open-record-2026-10-17-*`. + +The record-pattern slice (OR-05) followed: open-row patterns project by label +through one shared `project_labelled_field` kernel instead of guessing product +indices; the realize unit test, two required-Wasmtime source runs and the +official `FieldPuns`/`FieldConsPuns`/`ReservedWords` cases execute. The board +reached **279/340** on release suite +`7c9f8839dbd7cf73d63322e90ca2eefd5a904551c63cf73b76bb77b64673e2eb`, and the +debug workspace passed 2,056 tests. D-04 and README carried the 279/340 row. + +Next, the whole dirty checkpoint was committed the same day under the topics +"Run the configured library runner…", "Check rank-N arguments…", +"Give open rows one immutable labelled record store", and +"Select open-row record patterns by label"; later inspections should state +their measurements against those commits rather than a dirty tree. + +The open-row update slice (OR-06) then landed as a further commit: base and +update expressions lower exactly once in source order, the new owner +`Update` operation patches labels on one fresh store through the shared +helper machinery, missing runtime labels trap, and forged operands fail the +CC boundary. The board reached **282/340** on release suite +`8585a60b24a8a02eb8b5c132c596359422492e224f6199a133fefd2364476337`; the +official `ObjectUpdate`, `ObjectUpdate2`, `ObjectUpdater`, +`NestedRecordUpdate`, `NestedRecordUpdateWildcards` and derived-Functor +`2616` roots pass and the debug workspace passes 2,062 tests. D-04, README +and the runtime scoreboard record carry the 282/340 row. + +This is still not topic acceptance: the remaining +slices are OR-08/OR-09 +target bindings and the remaining gates. + +## Follow-up agent start point, 2026-10-17 (evening) + +Superseded by the OR-08/OR-09 resumption above: the slice named here as next +has since been implemented, validated and measured (284/340). The text below +is retained as the record of that start point. + +Start from a clean checkout at `b7778658`; nothing is pushed and no issue was +closed or commented. Current accepted evidence: + +- Runtime board **282/340** with all exits zero, 25 FFI groups excluded; + failure decomposition, release suite binary SHA-256 + `8585a60b24a8a02eb8b5c132c596359422492e224f6199a133fefd2364476337`, + Wasmtime 49.0.3, per-program rows in + `/tmp/psrs-open-record-2026-10-17-runtime-281/summary.json` (earlier + checkpoints 276 and 279 under + `/tmp/psrs-open-record-2026-10-17-runtime{,-checkpoint-276}/`). +- Full debug workspace 2,062 passed / 0 failed / 5 ignored with + `PSRS_REQUIRE_WASMTIME=1` and the isolated package + (`/tmp/psrs-open-record-2026-10-17-update-workspace.log`). +- Frontend boards are unchanged by the update slice; their last full run is + L1 904/908, L2 72/72 and passing resolution 403/413, L3 39/48, L4 39/50, + L5 77/97 (`/tmp/psrs-open-record-2026-10-17-boards.log`). +- `RowPolyInstanceContext.purs` still fails P5 typechecking; its + undetermined-variable pair prints in either order across repeated runs of + the identical binary — presentation noise, not a changed reason. + +Validation routine after any change (report any omitted scope honestly): + +```sh +cargo fmt --all --check +PSRS_STDLIB_ROOT=/tmp/psrs-symbol-reification-stdlib PSRS_REQUIRE_WASMTIME=1 cargo test --workspace +cargo clippy --workspace --all-targets -- -D warnings +``` + +Board re-measure procedure: rebuild the release suite binary +(`cargo test -p psrs-driver --test suite --release --no-run`), then measure +every program root with its own `PSRS_SUITE_FILTER` worker — three workers, +90-second cap, BigFunction under the separate 2 GiB RSS guard — reusing the +pattern of `/tmp/psrs-open-record-2026-10-17-measure.py` with a fresh output +directory. Land the new binary SHA-256, the changed failure set and the new +gate row in D-04, README and the runtime scoreboard record in the same commit +as the change, following [the authoring guide](../../authoring-guide.md). + +Remaining open-record slices, ranked by unblocking value (requirements in the +[execution checklist](open-records.md)): + +1. OR-08 source-typed runtime primitives and target bindings. This unblocks + the three missing-foreign program blockers — `Record.Unsafe.unsafeGet` + needs `Deriving.purs` and `TCOFloated.purs`, and + `Test.Assert.checkThrows` needs `TCOMutRec.purs` — for three more board + programs, and is the prerequisite for OR-09. Never fabricate a missing + foreign's result: keep the source contract and implement the real target. +2. OR-09 library record algorithms with official `Record.Unsafe` wrappers + under the vendoring contract, then OR-10 edge keys and unsafe-input + contracts. +3. OR-07 broader generic/recursive/imported interactions and OR-12 broader + canonical-ABI coverage as integration checks over the new update and + pattern paths. +4. OR-11 remaining malformed-IR rejection surfaces, then OR-13's full boards + to close the topic. + +Also still unsupported at the surface: pattern-driven tail binding +(`..rest`), recorded in OR-05's row. + +Guardrails that bound every slice: preserve official standard-library sources +and the store's honesty contracts; stage and runtime correctness are separate +claims; a local test passing is not acceptance — re-run the affected +scoreboard and record the number before closing anything; do not narrow the +design in [open records](../../design/backend/fp/open-records.md) to match an +implementation. `#124` stays In Progress on the board while these slices run. + +## OR-08/OR-09 resumption evidence, 2026-10-17 + +The current compiler work resumes from `b7778658` in the existing checkout; +compiler source and test changes are in progress there. The standard-library +package is now pinned at `e556bf2f8cb377c049d2ff0a5a97b484c7b36bc5`, with +source fingerprint `fnv1a64-v1:7d96f1c44d8d52cc`. The package checkout +`/tmp/psrs-open-record-stdlib-or09` is clean at that revision. The lock retains +the repository's sibling path `../psrs-stdlib`; use +`PSRS_STDLIB_ROOT=/tmp/psrs-open-record-stdlib-or09` for local compiler +commands so they consume the pinned package without changing the shared sibling. + +OR-09 has focused source and runtime evidence plus the fresh suite-board +measurement: + +- The final vendoring audit covers 50 packages and 309 modules, with no + upstream modules absent from the vendor. Its inventory and source comparison + are in `/tmp/psrs-open-record-or09-evidence/final-audit/`. +- The Node conformance suite passed 13/13 cases. The independent target oracle + report `/tmp/psrs-open-record-or09-evidence/oracle-edge-final/record-unsafe.json` + accepts the shared values case and the empty, missing, and inherited-key + trap cases. It records the target differences explicitly: Wasm has no + JavaScript `undefined` value or object prototype, and `__proto__` is an + ordinary own key in target records. The report uses CLI SHA-256 + `d91a5b9943b871e4a90997ce0f0fde2123336aa248a7ca12272cd690425dceac` and + confirms the package did not change during the run. +- With the same CLI and package, the official `Deriving.purs` and + `TCOFloated.purs` program roots each compiled and ran through the configured + library runner with exit 0 and exact stdout `Done\n`. Separate compile and + execution records are in `/tmp/psrs-open-record-or09-official-Deriving/` + and `/tmp/psrs-open-record-or09-official-TCOFloated/`. This resolves the two + `Record.Unsafe.unsafeGet` root blockers in focused evidence; it does not + establish the full L6/M7 board. The emitted Wasm SHA-256 values are + `4227351f326c51abcbe021a3911b3b173676d4e71c2b5f68535df2c24fb4e98c` + (`Deriving`) and + `b4eefee336e38789268930b32854a26acc3e0f4cb6f7224cd34b151ce5c9b9c1` + (`TCOFloated`). `TCOMutRec.purs` still needs a real + `Test.Assert.checkThrows` target contract. +- The three-module independent caller report + `/tmp/psrs-open-record-or08-independent-run-fixed/run.json` executes with + exit 42 using the earlier package fingerprint + `fnv1a64-v1:5fcbb57525c63eb1`. It is direct read/transport evidence, separate + from OR-09 and from the official-suite board. + +The fresh release-board run over all 365 program roots measured **284/340** +with all exit-zero completions and 25 FFI groups excluded +(`/tmp/psrs-open-record-or08-or09-runtime/summary.json`). Exactly +`Deriving.purs` and `TCOFloated.purs` leave the failing set — the two +`Record.Unsafe.unsafeGet` blockers above — and no other failure changed +state; the remaining 56 roots are 31 P5 typecheck, 11 P5 kind check, 5 P8 +closure/linking (3 closure capture, 1 recursive local binding, +`Test.Assert.checkThrows`-blocked `TCOMutRec.purs`), 5 P3 resolve, 2 P0 lex +(DEC-16), one 10-second runtime timeout, and the bounded BigFunction +compilation, which reached the measurement cap near the 2 GiB guard before a +diagnostic. OR-09 does not close the topic: OR-07 broader +generic/recursive/imported interactions, OR-10 edge-key and unsafe-input +coverage, OR-11 malformed-IR surfaces, OR-12 broader canonical-ABI coverage, +and OR-13 full-board acceptance remain open. `#124` remains In Progress; no +push, issue comment, or closure was made for this resumption. + +### Validation checkpoint: integration fixes and final rerun + +With `PSRS_STDLIB_ROOT=/tmp/psrs-open-record-stdlib-or09` and +`PSRS_REQUIRE_WASMTIME=1`, formatting passed. The first workspace run had 523 +passes and five failures because legacy ABI, coverage and uninhabited-layout +fixtures lacked the newly required `ordinary_data_ids` evidence; the fixtures +were updated to provide the valid producer metadata. The next run reached the +source-layout tests after all 529 backend tests passed, then rejected new flat +siblings for the record-build and operations modules. Those files were moved +into their owning module directories; the focused source-layout test passes +1/1 and direct record-build execution passes 2/2. These were fixture and source +organization repairs; the verifier requirements remain intact. + +An integrated workspace run passed 2,077 tests, with zero failures and five +ignored, using the new package; its log is +`/tmp/psrs-open-record-or08-or09-workspace-integrated.log`. + +The final-tree validation is accepted. Formatting and source layout pass, and +Clippy passes with warnings denied. The full debug workspace with +`PSRS_REQUIRE_WASMTIME=1` and the new package passes 2,084 tests, failing +zero, ignoring five +(`/tmp/psrs-or08-final-tree-workspace.log`). The release suite rebuilds with +SHA-256 +`37bcdd007466f2ed91f2cbd06962c584bcdd00caef10f73465a5070f36d1f4ee`, and the +board re-measurement landed as the 284/340 row above and in +[D-04](../../design/D-04-suite-roadmap.md), +[README](../../../README.md), and +[runtime-program-scoreboard](runtime-program-scoreboard.md). Frontend boards +were not rerun; the slice changed only backend, runtime and library content, +and their numbers are unchanged from the 2026-10-17 frontend confirmation. + +### Scope of the opening checkout state + +The opening `State` and `Committed state` paragraphs describe only the earlier +`b7778658` checkpoint. The compiler follow-up and lock update above it are now +committed, and the resumption sections describe that state. The separate +library package at `e556bf2f` is a clean local commit in +`/tmp/psrs-open-record-stdlib-or09`; it has not been pushed. Take the accepted +final-tree validation and board numbers from the resumption sections, not +from the opening snapshot's 282/340 row, which belongs to the earlier package +and source state. + +## Partial-exhaustiveness slice resumption, 2026-10-17 (S1 landed, S2 blocked): in-progress checkout + +This section supersedes the "Next move" lists above for the current checkout: +`master` is at `7dd45e4b` and the compiler work for the checker-side +`Partial` flow is present as **uncommitted changes** (29 modified files plus +untracked `crates/psrs-coverage/`, +`crates/psrs-typecheck/src/typecheck/infer/coverage.rs`, and a temporary +repro test). Nothing is pushed; `#124` remains In Progress; the existing +stashes on the `stdlib/vendor-core-libraries` history predate this work and +must be left alone. + +### S1 is complete and verified in the working tree + +- `crates/psrs-coverage/` is the single owner of usefulness analysis: + `src/{lib,pattern,types,engine,analyze,render,tests}.rs` port the backend + engine verbatim, including the two top-level guards + (`all-Any` row short-circuit and the `(matrix, query)` active-state scoped + set) that make the uninhabited recursive-product path terminate — omitting + either stack-overflowed that test. Registered in the workspace `Cargo.toml` + members and `[workspace.dependencies]`. +- The backend adapter `crates/psrs-backend/src/cc/case/coverage/mod.rs` is a + thin shim keeping `analyze(&Module, TypeId, &[CaseBranch]) -> CoverageReport`; + the old backend `engine.rs`/`render.rs` were deleted and + `decision`'s `canonical_literal` re-export trimmed. Before the S2 + instrumentation described below, `cargo test -p psrs-backend` passed 535/0 + and clippy (`-p psrs-coverage -p psrs-backend -D warnings`) was clean. + +### S2 checker integration is functionally in place (with instrumentation still in the tree) + +- `crates/psrs-typecheck/src/typecheck/infer/coverage.rs` (new, registered in + `infer/mod.rs`; `psrs-coverage.workspace = true` added to the crate's + `Cargo.toml`) interns `InferType` into skeleton ids + (`Constructor/Applied/ForAll/Constrained/RowExtend/RowEmpty/`...`/Unknown`) + and implements `psrs_coverage::Types`. Constructor rows are elaborated + lazily to a fixpoint with `instantiate_constructor` + `resolve_type` on + throwaway fresh unknowns; coverage never unifies anything. + Two landmines already found and fixed: + - Types with **zero** constructors (uninhabited/foreign) must still be + recorded as examined (`rows.insert(type_id, Vec::new())`) or the fixpoint + scan livelocks; + - `record_fields` answers the **known field list for open-row tails** (a + row-variable tail means the record may grow beyond the pattern's labels, + which the listed pattern still covers). Closed-record handling in the + *backend* adapter must not change; there the rows are already solved. + Without this, every `{ val, acc }`-style partial record pattern in the + standard library (where-bound `go` records) reports a bogus missing + case. +- `infer/case.rs` replaces the given-only fallback block: a non-empty case + whose analysis is non-exhaustive pushes one report-only `Partial` wanted + carrying `witness: Option>>` (new field on + `WantedConstraint`, set `None` at every other construction site) and always + attaches the guard-desugar-style `PartialFallback` branch; the old + given-in-scope attach remains as the conservative path. Empty cases (no + branches) still take the backend trap path, deliberately out of this + slice's scope. +- `classes/evidence/finalize.rs::abstract_dictionaries` now **skips + report-only constraints** (via `prim::is_report_only`) — no dictionary + parameter is abstracted and no `Abstracted` solution is recorded for them. + Retaining a `Partial` constraint previously abstracted an empty-record + dictionary parameter, which shifted every value's application shape in + THIR and broke the open-record regression test; that skip is what makes + retained `Partial` schemes honest AND layout-neutral. +- Solver facts confirmed by experiment (do not re-litigate): report-only + classes do not precede givens, so `unsafePartial` residents discharge by + given lookup first; the retention hook at + `classes/solve/search.rs` (`is_report_only && policy_keeps_unsolved`) + already retains arity-0 `Partial` under a `Retain` policy, so + `solve_partial` is deliberately untouched; `RequireSolved` (annotated + declarations) reaches the rule and reports. The single wording point is + `prim/reports.rs::unresolved_report_error` (extend it with the official + "A case expression could not be determined to cover all inputs. ..." text + from the wanted's `witness`), with the `NoInstance` error at + `classes/solve/entry.rs` as the backstop for the same message. + +Observed end-to-end with `PSRS_STDLIB_ROOT=/tmp/psrs-open-record-stdlib-or09`: +`f x [y, z] = x y z` (argument-pattern lambda) now compiles through P8 — +retained `Partial`, no dictionary parameter, fallback reaches the backend +matrix. An annotated non-exhaustive case rejects at P5, currently with the +generic `no instance for constraint Partial` wording (official wording is +the pending reports.rs change). The `unsafePartial` probe showed five +standard-library `Partial` diagnostics **before** the open-row `record_fields` +fix above; it has not been re-verified after that fix because the blocker +below interrupts compilation. `cargo test -p psrs-typecheck` passes 135/0 +including `infers_an_open_record_row_from_a_partial_record_pattern`. + +### Blocking disorder: stack overflow in the backend CC layout pass + +Compiling prelude + `/tmp/psrs-open-record-stdlib-or09/lib/Data/List/Types.purs` +overflows the stack (default 8 MB; a 512 MB thread completes, so the +recursion is pathological/deep rather than infinite). Reproduction test +(new coverage instrumentation to remove at cleanup): +`crates/psrs-driver/tests/repro_coverage_overflow.rs` with `PSRS_COVERAGE_ON=1 +cargo test -p psrs-driver --test repro_coverage_overflow`. + +Bisection evidence, all via temporary eprintln probes listed below: + +1. Typecheck side: all 472 checker `case_coverage` analyses enter and + complete; the interner stays tiny; the engine's `useful_inner` gets + called once per analysis. +2. Frontend pipeline (`lowering.rs`): resolve, typecheck, per-module Core + lowering, link, and Core verification all complete + (`core-verify-done frontend-ok`). +3. Backend pipeline (`pipeline/mod.rs`): core optimization and external + binding extraction/conformance complete; the crash occurs inside + `cc::lower_module_with_relations` BEFORE any case is compiled + (`DBG-CC-CASE` never prints), after primitives, local-row + instantiation, and type ids are registered and while iterating layout + work: the last printed marker is + `DBG-LAY append-function-types-begin`, so the overflow is inside or + directly around `cc/layout/functions.rs::append_function_types` + (between it and `aggregate::normalize_aggregate_layouts`). +4. The abort shows a hashbrown layout calculation with astronomic values + (~4.3e9 buckets), and the backtrace collapses to one frame — instrument + instead of trusting post-abort traces. +5. Negative control that explains earlier confusion: the env-gated + "pretend every case is non-exhaustive" mode also reached many P5 + reports and dropped out before the backend ever ran, so its earlier + passing runs say nothing about the backend path. + +Working hypothesis to test first: the checker now attaches the +`PartialFallback` branch (whose value is a nested `case 0 of` with +zero branches) to genuinely non-exhaustive cases across the whole standard +library; those newly embedded nested cases change the core declarations the +function-signature pass walks. Confirm the actual recursion +(suspects: function-type typing recursion or an `HashSet`/`HashMap` keyed +insertion loop in that pass or its `functions.rs` helpers, e.g. signature +interning over deeply nested arrow/case types) before changing code; a +targetContentLoaded comparison of core tables across this module with +`PSRS_COVERAGE_ON` off — where P5 diagnostics abort before the backend — +will isolate whether the *core* input differs at all from pre-slice state +for the cases that survive typecheck. + +### Instrumentation to remove before any commit + +All temporary debug markers below must be removed before any commit: + +- `crates/psrs-typecheck/src/typecheck/infer/case.rs`: the + `PSRS_COVERAGE_ON` / `PSRS_ANALYZE_NOOP` / `PSRS_WITNESS_ONLY` / + `PSRS_FALLBACK_ONLY` env-gated analysis selection; restore the plain + `self.case_coverage(...)` call. +- `crates/psrs-typecheck/src/typecheck/infer/coverage.rs`: `DBG-COV`, + `DBG-COV-CALL`, `DBG-INTERN` eprintlns, the `debug_skeleton` helper, and + the `static CALLS` counter. +- `crates/psrs-coverage/src/engine.rs`: the `static CALLS` counter and + `DBG-ENGINE` eprintln in `useful_inner`. +- `crates/psrs-typecheck/src/typecheck/classes/solve/entry.rs`: + `DBG-ENTRY` markers (both the retained and no-instance branches). +- `crates/psrs-typecheck/src/typecheck/prim/dispatch.rs`: the + `DBG-DISPATCH` marker. +- `crates/psrs-driver/src/program/lowering.rs`: all `DBG-PHASE` markers. +- `crates/psrs-driver/src/program/library.rs`: the `DBG-LIB-ORIGIN` + marker inside `shift`. +- `crates/psrs-driver/src/prelude/mod.rs`: the stray + `#[cfg(feature = "never")] debug_print_library(...)` line (emits an + unexpected-cfg warning). +- `crates/psrs-cli/src/build.rs`: the `DBG` marker above the + library-diagnostic print. +- `crates/psrs-backend/src/cc/mod.rs` (`DBG-CC*`), + `crates/psrs-backend/src/cc/layout/mod.rs` (`DBG-LAY*`), + `crates/psrs-backend/src/cc/case/mod.rs` (`DBG-CC-CASE`), + `crates/psrs-backend/src/pipeline/mod.rs` (`DBG-PHASE`). +- Delete `crates/psrs-driver/tests/repro_coverage_overflow.rs`. + +After cleanup re-run the real-flow probes (`f x [y, z] = x y z` +acceptance, annotated rejection wording, `unsafePartial` acceptance), the +three targeted `Let.purs`/`ShadowedTCOLet.purs`/`Where.purs` source tests, +then the slice's standing plan: extend `unresolved_report_error` and the +`entry.rs` fallback with the official wording, full workspace validation +(`cargo fmt --all --check`, `cargo test --workspace`, +`cargo clippy --workspace --all-targets -- -D warnings`, +`PSRS_REQUIRE_WASMTIME=1`), release suite rebuild + SHA-256, and the full +board re-measure (worker pattern +`/tmp/psrs-open-record-or08-or09-measure.py`, 3 workers, 90 s cap + RSS +guard) expecting 287/340. Keep the same-commit discipline: design-document +change, code, and tests land together per topic. + +## Partial-exhaustiveness follow-through + +The current checkout preserves the original S1/S2 changes and completes the +checker integration without environment-gated semantics. All listed temporary +instrumentation and the temporary overflow test were removed. No branch, +worktree, push, or issue closure was made. The isolated library remains clean +at `e556bf2f` with the existing lock fingerprint; no official source was edited. + +### Repairs to the blocked checkpoint + +1. The overflow was an actual transparent newtype cycle in official + `Data.Void` (`newtype Void = Void Void`), rather than the coverage engine or + a finite deep arrow. Signature tracing showed `scalar_type` repeatedly + expanding the same declaration. The common uninhabited-storage predicate + now recognizes transparent declaration cycles, including mutual cycles, + and uses erased references. Conversion stops at that boundary, and integer + box reservation includes such erased slots. Direct layout tests cover self + and mutual cycles; a source execution coerces a boxed scalar through a + cyclic newtype constructor pattern and recovers 42. Constructor rows remain + available to coverage: representation uninhabitedness must not erase the + source pattern signature. +2. The S2 skip in `abstract_dictionaries` was inconsistent with signature and + use-site elaboration. It was removed: inferred and declared `Partial` + constraints use the existing common dictionary convention. Skipping only + abstraction caused `invalid THIR: global reference is not a valid scheme + instance` when an inferred partial function was actually called. The new + array-argument-pattern test calls it under `unsafePartial` and exits 42; + the existing explicit-Partial test still checks both 42 and a trap. +3. Every nested pattern type is resolved after all branches have unified, + before the checker adapter interns it. Resolving only the scrutinee left + stale unknowns in the engine's exemplar-field types. Official + `Data.List.Lazy.drop` then incorrectly demanded `Partial` for complete + `Nil`/`Cons` alternatives. A reduced recursive two-argument stepper executes + with result 42, and the original guarded-library typecheck passes. +4. Missing-case witnesses now reach the shared `NoInstanceFound` report + formatter and the solver's fallback. Annotated cases reject at P5 with the + official explanatory wording and exact source span. Inferred declarations + retain `Partial`, and authorized fallbacks keep first-match behavior through + Core/CC. The report prints the first witness plus an ellipsis, not a claim + to enumerate all missing cases. Synthetic multi-scrutinee products still + render as a record witness; exact official multi-column pretty printing is + not established by the error-code scoreboard. Empty source cases remain + outside this slice. The local stepper fixture now uses primitive `Partial`; a user class + merely named `Partial` cannot authorize non-exhaustiveness. + +The original complete-`Data.List.Types` probe now terminates on its small +thread stack and reports the separate P8 limitation `recursive local binding +is not a function`; this is not compile acceptance for that whole module. +Evidence: `/tmp/psrs-coverage-resume-fixed.log`. + +### Acceptance evidence + +The final release suite SHA-256 is +`bab10d4c7480c7db64fce9dacba8c96c035f9e760a810679a9d2a750a59233a3`. +The full frontend remeasurement is L1 904/908, L2 72/72 with passing resolution +403/413, L3 39/48, L4 39/50, and L5 **81/97** (`NoInstanceFound` **52/53**). +Relative to the same-package baseline, `2806`, `4466`, +`NonExhaustivePatGuard`, and `Superclasses5` now agree; no L1–L4 number moves. +Evidence: `/tmp/psrs-partial-frontboards-final.log` and the baseline +`/tmp/psrs-partial-frontboards-baseline.log`. + +The final runtime board is **287/340**, all successes exiting zero, with 25 +FFI groups excluded. Exactly `Let`, `ShadowedTCOLet`, and `Where` recover; +there are no regressions. The remaining 53 split into 31 P5 typecheck, 11 P5 +kind check, 2 P8 closure/linking, 5 P3 resolve, 2 P0 lex, one runtime timeout, +and bounded BigFunction compilation. The guarded BigFunction run times out at +90.89 seconds with peak RSS 1,485,792 KiB; the 2 GiB guard stays enabled. +The worker manifest contains the 364 ordinary completed rows plus that +separate guarded result after the sandbox blocked the first RSS query. +Evidence: `/tmp/psrs-partial-final-runtime/summary.json` and +`big-function-checked.json`. Formatting, source layout, and strict workspace +Clippy pass. The final full workspace with +`PSRS_STDLIB_ROOT=/tmp/psrs-open-record-stdlib-or09` and +`PSRS_REQUIRE_WASMTIME=1` passes **2,093 tests, zero failures, five ignored**; +see `/tmp/psrs-partial-workspace-final.log`. Relative to the previous 2,084-test +log, the inventory adds one backend cycle-layout test, three shared-engine +tests, and five driver regressions. No prior tests were removed from that +inventory. All five ignored scoreboard groups were exercised separately by +the release frontend boards and per-program runtime run. + +Formatting and strict Clippy use the standard full-workspace commands. The +original seven driver failures in the first integration attempt were four +outdated backend-diagnostic assertions, one fixture that shadowed primitive +`Partial`, one confirmed nested-pattern-type failure in the original library case, and +one handler integration failure. Their corrected paths all pass in the +final full run. The superseded first workspace/board runs were stopped; only +the final evidence paths above establish acceptance. +The open-record topic remains open: retain the OR-07, OR-10, OR-11, OR-12 and +OR-13 obligations in the checklist rather than treating this frontend slice +as their complete acceptance. + +## Generic local rows and nested ABI follow-through + +The current continuation preserves the earlier coverage/Partial changes and +removes an obsolete correctness dependency in P8. CC no longer calls +`instantiate_local_rows`; the dedicated finite local-row pass and its private +expression-instantiation helper are removed. Normal declaration specialization +and its capture-avoiding substitution remain available to Core optimization. +The representation owner, generic call adapters and ordinary Core verification +now carry this boundary directly, as required by DEC-22 and the open-record +design. No library source, lock, branch, commit, push or issue state changed. + +### Focused evidence and test ownership + +- OR-07: `tests::records::open_rows` passes 17/17 with required Wasmtime. The + new `composition` module adds four tests: unoptimized Core with one captured + generic reader shared by two distinct closed rows; imported recursive + type-changing updates with retained nested callable/Array/ADT payloads; + a captured local polymorphic reader passed at an open higher-order use type; + and captured recursive local updates used at two different tail shapes. + Each program exits 42. The first test explicitly bypasses Core optimization + and counts the generic reader before executing the emitted artifact. +- Baseline: `/tmp/psrs-or07-before-local.log` and + `/tmp/psrs-or07-without-local-preparation.log` both execute the existing local + captured-row reproducer. Negative control: + `/tmp/psrs-or07-specialization-negative-control.log` fails the new structural + assertion with the old preparation restored; current focused acceptance is + `/tmp/psrs-or07-all-source.log`. +- OR-11: the public CC verifier rejects wrong operand types, missing operand + declarations, wrong result types and inconsistent embedded destinations for + all five read/update operations. Its expected types are specified separately + from the implementation helpers; diagnostics retain the original span. + Evidence: `/tmp/psrs-or11-operand-matrix.log`. +- OR-10: all five read/update operations execute both valid and mismatched + label/payload-array inputs. Each malformed input traps before any later + unpack, including an ignored result and an empty Update. Physical CC/MIR + verification succeeds: these are runtime-content tests, not malformed + representation-schema tests. Evidence: + `/tmp/psrs-or10-malformed-store.log` (ten executions). +- OR-12: the new filesystem test sets exact access/modification timestamps, + reads nested `Either`/`Maybe` record payloads, transports the descriptor + record through `Box (Array a)`, updates its size through an open row while + retaining timestamps, then sends the recovered timestamps through the ABI + again. Source assertions verify all scalar fields and the unchanged input; + host metadata independently verifies the outbound modification timestamp. + Evidence: `/tmp/psrs-or12-timestamp-roundtrip.log`. + +### Current validation + +Formatting and strict workspace Clippy pass at this continuation: +`/tmp/psrs-or07-fmt.log` and `/tmp/psrs-or07-clippy.log`. The new release suite +is `/tmp/psrs-or07-suite`, SHA-256 +`52a9b77e706fd2030ed8c235c75f3a313ee118a18de0b49e4be21eb375e03df1`. +Full workspace validation passes **2,100 tests, zero failures and five +ignored** (`/tmp/psrs-or07-workspace.log`), separately from the historical +2,093-test checkpoint. All five ignored scoreboard groups execute separately +in the frontend/runtime remeasurements recorded below. The seven additions +are four open-row composition tests, one nested ABI test, one public CC +negative matrix and one direct GC runtime-content matrix. No existing test +was removed. The driver unit group passes 814/814, and all six locked +control-reference integration tests execute and pass. The same clean package `e556bf2f` / +`fnv1a64-v1:7d96f1c44d8d52cc` is selected via +`PSRS_STDLIB_ROOT=/tmp/psrs-open-record-stdlib-or09`. + +The current CLI also reruns the library-owned `Record.Unsafe` oracle: +`/tmp/psrs-or07-record-oracle/record-unsafe.json` accepts its value program and +all three explicit empty/missing/inherited get traps; the Node conformance +suite passes 13/13 (`/tmp/psrs-or07-node-conformance.log`). This refreshes the +previous OR-09/OR-10 oracle against the current compiler instead of relying +only on its old artifact. The package fingerprint stays unchanged. +The rebuilt frontend scoreboards also finish with unchanged L1 904/908, +L2 72/72 (passing resolution 403/413), L3 39/48, L4 39/50 and L5 81/97 +(`NoInstanceFound` 52/53): `/tmp/psrs-or07-frontboards.log`. + +The current runtime board is **287/340** with no success-set changes. The +manifest combines 364 completed ordinary roots (25 FFI exclusions) with the +separate bounded BigFunction result. BigFunction reaches the 2 GiB RSS guard +after 27.89 seconds, with observed peak 2,141,824 KiB; this supersedes the +previous checkpoint's 90.89-second timeout for the current artifact, without +claiming BigFunction compile acceptance or a performance repair. The remaining +53 still split as 31 P5 typecheck, 11 P5 kind check, 2 P8, 5 P3 resolve, +2 P0 lex, one runtime timeout and BigFunction. Evidence: +`/tmp/psrs-or07-runtime/{summary.json,results.jsonl,big-function-checked.json}`. + +### Next topic acceptance review + +Start from the committed checkpoint above and preserve the clean development +package. The concrete OR-07 prerequisite and the previously listed operation/ABI test gaps are now +covered. Before declaring the whole topic complete, review OR-01–OR-12 as one +integrated contract, including the remaining OR-04/OR-05 interaction language, +and update each requirement's status from its actual source/direct-IR/runtime +evidence. Do not infer full topic acceptance from the 287/340 corpus count. +Do not change official source or substitute `Test.Assert.checkThrows` merely to +improve that count; its target behavior remains independently unimplemented. + +The final slice review confirms that the removed expression-substitution path +was used only by the deleted local-row pass; the declaration-specialization +path retains its previous capture-avoidance and quantified-binding behavior. +All changed/new Rust files meet the 500-line rule. `git diff --check` passes. +The source, tests and corresponding design/checklist updates are now committed +as `e5972241` and `e331f9ba`; no existing changes were discarded. The opening +handoff snapshot records their exact state and the outstanding topic review. + +## Receiving agent outcome, 2026-10-18 + +The integrated OR-01–OR-14 review from the receiver start point is complete. +Seven open-row interaction regressions (`tests::records::open_rows::interactions`) +resolve the remaining OR-04/OR-05 combination statements without compiler +repairs, every row's evidence was re-executed on the current tree, and the +board re-measurement with the correct suite binary (`52a9b77e...`) reproduces +287/340 with an identical failure list. Checklist, D-04 and scoreboard updates +land together in the review commit. Two lessons worth keeping: the +`target/release/deps` directory retains stale `suite-*` executables, so copy a +suite binary by newest mtime, never by name glob; and `PSRS_STDLIB_ROOT` must +be set for driver tests that compile with the package, or hundreds of unrelated +tests fail. `Test.Assert.checkThrows` and the `BigFunction` guard remain +compiler-wide limitations, unchanged and unclaimed. diff --git a/docs/implementation/backend/open-records.md b/docs/implementation/backend/open-records.md new file mode 100644 index 00000000..2e5f3bac --- /dev/null +++ b/docs/implementation/backend/open-records.md @@ -0,0 +1,485 @@ +# Open Record Implementation Acceptance + +**Design:** [Open record rows](../../design/backend/fp/open-records.md). +**Status:** Complete as of the 2026-10-18 integrated review. Historical +evidence is recorded below by checkpoint; the final section records the +requirement-by-requirement audit and its fresh board measurement. + +All design requirements apply, including adjacent erased and canonical-ABI +boundaries. Complete each required vertical slice before declaring this topic +complete. Source, direct IR, malformed IR and executed runtime evidence remain +separate. Missing Wasmtime is a failure. + +| ID | Requirement | Evidence / status | +| --- | --- | --- | +| OR-01 | One checked immutable storage schema; reject stale/wrong references, nullability and element kinds | CC schema tests pass, including eight malformed mutations. MIR owner/physical-schema tests reject five mutations. | +| OR-02 | Open rows retain full storage independently of prefix and instantiation | Shared-layout test and source accessor/identity runs pass across distinct rows and field indices. Updates are tracked in OR-06. | +| OR-03 | Closed-to-open pack and open-to-closed recovery with complete label validation | Source return recovery and direct CC/MIR round trips execute; wrong labels and payload lengths trap. | +| OR-04 | The same store owns bare erased record transport, recursively through arrays, records, ADTs and callable adapters | Eight source runtime tests cover Array/ADT erased slots, independent nested rows, callable fields and callable results. A further seven required-Wasmtime interaction regressions (`open_rows::interactions`) cover a record passing through a bare `forall a. a -> a` erased slot and reopening at an open-row use, a callable field extracted by pattern and applied immediately, a record pattern inside a constructor scrutinee, and one value recovered from an erased ADT slot reused at two use types. | +| OR-05 | Known fields and record patterns select by label, including differing prefixes and canonical indices | Known-field selection executes through shared find/read kernels. Open-row record patterns project by label through the same kernel: a realize unit test proves no product projection against the store, and two required-Wasmtime source runs exercise differing prefixes plus nested open-row values. `FieldPuns.purs`, `FieldConsPuns.purs` and `ReservedWords.purs` additionally execute in the official corpus. Four further required-Wasmtime interaction regressions cover record patterns nested in constructor scrutinees, two pattern columns selecting their own labels, a wildcard field label that neither binds nor consumes the tail, and a guard reading a pattern-bound label. Pattern-driven `..rest` tail binding stays unsupported and rejected by design. | +| OR-06 | Immutable updates preserve unknown tails and type-changing field results, with exact evaluation order/count | Open-row updates lower through the owner's `Update` operation: base and update expressions lower exactly once in source order, replacement payloads erase through the common planner, and the runtime kernel patches existing labels on one fresh store while keeping the complete label/value sequence. Direct CC/MIR/GC runs replace a label and recover the rest, a missing label traps, forged operands fail the CC boundary, and three required-Wasmtime source runs cover source-order multi-label updates, a type-changing field over a retained tail, and a case-branch update. Official `ObjectUpdate.purs`, `ObjectUpdate2.purs`, `ObjectUpdater.purs`, `NestedRecordUpdate.purs`, `NestedRecordUpdateWildcards.purs` and derived-Functor `2616.purs` execute. Broader record library coverage is OR-09's scope. | +| OR-07 | Generic/recursive/imported uses execute without finite specialization as a correctness prerequisite | Verified for this slice: unoptimized Core lowers to one captured generic reader serving two closed rows and exits 42; a negative control restoring the removed mandatory preparation fails the single-reader assertion. Four composition programs additionally cover imported type-changing updates, recursion, nested callable/Array/ADT payloads, higher-order open uses and captured recursive local updates. All 24 open-row source tests (17 plus the 7 integrated-review interaction regressions) execute with required Wasmtime. Ordinary optimization remains optional. | +| OR-08 | Source-typed runtime primitives preserve checked operand/result types and slot policy before ABI erasure | Runtime-owned contracts describe `length`, `key_at`, `payload_at` and `build`, including their projection to the one CC `RecordOperation` owner. P8 resolves bare or applied slot types to positive ordinary constructorless declaration evidence; P9 preserves and checks the provider export against the projected operation and rejects direct calls bypassing it. The driver module passes 6/6 with required Wasmtime (five executions including an imported applied-carrier slot, plus one unused invalid-signature rejection). | +| OR-09 | Library-owned searches and official unsafe wrappers; pinned source and JS oracle comparisons | Implemented for the four `Record.Unsafe` operations: the pinned package preserves the official wrappers and signatures, with algorithms in `PSRS.Record`; the package audit finds 50 packages/309 modules and no missing upstream module. Node conformance passes 13/13, and the independent Wasm oracle accepts the common values and three explicit trap cases. See the OR-09/OR-10 evidence below. The 2026-10-18 integrated review reran the conformance suite and oracle on the current tree. | +| OR-10 | Empty, Unicode, reserved-looking and absent keys; invalid unsafe-input target differences explicit | Partial: required-Wasmtime provider tests and the JS/Wasm oracle cover empty storage, private-use and supplementary Unicode labels, `__proto__` as an ordinary own key, absent/inherited get traps, and target divergence from JavaScript `undefined` and prototype lookup. Five direct operation fixtures additionally trap on mismatched label/payload lengths before any later unpack; each has a valid executed control. Nested ABI transport is exercised in OR-12. The 2026-10-18 integrated review reran the provider tests and all five malformed/mismatched fixtures on the current tree. | +| OR-11 | CC and MIR reject malformed schema, plans, missing fields, mismatched endpoints and evidence | Eight CC-schema, six CC-plan and five MIR-owner/physical mutations reject. Six P9 provider-boundary rejections cover the checked registry (a valid base passes, while a second registered provider, a missing runtime binding, an export for another operation, a direct-call bypass and a noncanonical external signature reject); five direct build-input mutations trap. A public CC-boundary matrix now independently checks all five read/update operation contracts: every operand rejects wrong shapes and missing declarations, and every result/destination mismatch rejects with the original span. The 2026-10-18 integrated review reran the schema, plan, owner and boundary-matrix tests on the current tree. | +| OR-12 | Canonical ABI and parameterized ADT fields use the same record storage policy | Erased record lists encode/validate both ABI directions, String and flags fields. Four filesystem programs execute. The nested timestamp test checks actual `Either`/`Maybe` record payloads through an ADT of arrays, an open-row immutable update retaining unknown fields, and host metadata confirms the outbound timestamp. The 2026-10-18 integrated review reran the ABI and filesystem tests on the current tree. | +| OR-13 | Value-sensitive required-Wasmtime programs, official TCOFloated/Deriving cases, and full scoreboards | The shared coverage/`Partial` follow-through remeasures all 365 roots at 287/340 with no regressions; `Let`, `ShadowedTCOLet`, and `Where` recover, and the earlier `TCOFloated`/`Deriving` successes remain. L5 reaches 81/97, with L1–L4 unchanged. The integrated review re-measured the board at the review tree with the identical 52-case failure list and the same suite binary identity (see the final section). | +| OR-14 | Formatting, full workspace tests, Clippy, final diff and design review | Formatting, source layout, full workspace and strict Clippy pass at the 2026-10-18 integrated review: **2,107 passed / 0 failed / 5 ignored** with required Wasmtime and the locked package selected explicitly (`/tmp/psrs-or0405-workspace-full.log`, `/tmp/psrs-or0405-clippy.log`). All five ignored scoreboard groups were exercised separately at earlier checkpoints and the board groups execute in the remeasurement. The integrated review's diff and the owning design document were reviewed together; all fourteen rows are verified. | + +## Starting evidence + +On 2026-10-17, compiler base `fe6dd78f` plus the command, scoreboard and +contextual-application changes (committed later the same day), package `6fcef6cc` / +`fnv1a64-v1:5fcbb57525c63eb1`: + +- Direct `unsafeGet "value" { value: 42, label: "x" }` fails at P8 library + linking because `Record.Unsafe.unsafeGet` has no target implementation. +- Ordinary `readValue :: forall r. { value :: Int | r } -> Int` fails at P8 + closure conversion because open rows have no runtime layout. +- The official `Ord` instance on `{ value :: Int }` reaches the same missing + `unsafeGet` binding. This is a real dependency of `TCOFloated.purs`. + +Trace diagnoses are `/tmp/psrs-record-boundary/{Direct,Open,Ord}.json`, with +matching sources and logs. They distinguish the foreign binding gap from the +open-row ABI gap. The pre-workspace accepted baseline was 2,037 tests and +273/340 programs; the post-repair checkpoint above holds +2,053 tests and 276/340 programs. Those numbers do not establish acceptance of +this topic. + +## Read/transport integration evidence + +The source tests are `psrs-driver`'s `tests::records::open_rows`: eight required +Wasmtime runs, all exiting 42. The original simple accessor also passes through +the library-owned executable runner at +`/tmp/psrs-record-boundary/Open-run/run.json`. The initial Array/ADT tests exposed +cast failures while erased records still used products. Switching all bare +record payloads to the same labelled store fixes both; those failures are +retained in `/tmp/psrs-open-record-source-tests.log`. + +Direct IR tests cover a String preceding nested ArrayMap control flow. Physical +non-null fields, including String, now use one shared persistence/restore +operation across nested conversion labels. Record operations and ABI aggregate +conversions use shared generated helpers so their internal loops do not change +the caller's live-reference scope. + +The first canonical-ABI integration run rejected filesystem record lists. The +ABI now retains the original logical product and the owner's physical store as +one view, and nested list copies retain complete Field descriptors rather than +only erased shapes. Three filesystem programs pass again with actual Wasmtime; +record/string/flags list fixtures validate load and store encodings. The new MIR +checks tie that view to its registered schema and physical GC definitions. + +Evidence logs include `/tmp/psrs-record-composition-tests.log`, +`/tmp/psrs-record-checkpoint-tests.log`, `/tmp/psrs-record-abi-storage-tests.log` +and `/tmp/psrs-record-filesystem-owner-verified.log`. The initial workspace run +stopped at source-layout rules, not a backend test failure (518 backend tests +passed). Module roots were moved into directories with `mod.rs`; the focused +source-layout check passes. + +## Post-repair checkpoint, 2026-10-17 + +The machine control pair previously published a private two-field product where +the common erased-record protocol required one. The final repair plans one +conversion in each direction from the common payload planner +(`cc/lower/control/pair.rs`), checks the complete expected plan at its exact +ordered location (`plan/boundary/{ops,primitives}.rs`), and keeps a general +helper-selection rule: a fixed `RecordPack` over all-`Identity` fields needs no +helper because it emits only projections and allocations. No machine pair +special case owns the rule. + +That checkpoint passes every gate: + +- With `PSRS_REQUIRE_WASMTIME=1`: all six `tests::control_costs` tests, all 32 + `tests::control_regressions` tests, and all 29 `tests::records` tests + (including the open-row runs and the updated + `typechecks_an_open_record_row_by_label`, which now requires its honest exit + 1 while `setX` is unsupported). +- All **518 backend tests** and the full workspace: **2,053 passed, 0 failed, + 5 ignored** scoreboard tests. Formatting, the source-layout check and Clippy + with warnings denied all pass. Evidence: + `/tmp/psrs-open-record-2026-10-17-workspace.log`. +- The frontend boards are unchanged at this tree: L1 904/908 (58 excluded), + L2 72/72 and passing resolution 403/413, L3 39/48, L4 39/50, L5 77/97. + Evidence: `/tmp/psrs-open-record-2026-10-17-boards.log`. +- The runtime board remeasured **276/340** (25 FFI groups excluded), the + per-program decomposition in + `/tmp/psrs-open-record-2026-10-17-runtime-checkpoint-276/summary.json`. + Relative to the 273 baseline, `1807.purs`, `NestedRecordUpdate.purs` and + `ObjectGetter.purs` newly pass; every failing reason otherwise matches the + pre-repair measurement. The release suite binary is + `61d4dea7488511191630dcf2adb060f2de9d62701786893434b6b6f955212b7e`, on + compiler `fe6dd78f` plus the changes this handoff covers, committed + the same day, with + package `6fcef6cc` / `fnv1a64-v1:5fcbb57525c63eb1` and Wasmtime 49.0.3. + +This satisfies OR-14's status condition at a read/transport checkpoint, not the +topic's completion condition: OR-05 record patterns, OR-06 updates, OR-08 +target bindings, OR-09–OR-10, remaining OR-11 rejections, broader OR-12/OR-07 +interactions, and OR-13 official cases stay open, all recorded in the table +above. D-04 promotes the 276/340 row to the gate table. + +## Record-pattern slice: 2026-10-17 (later the same day) + +Open rows used to fail record patterns with an +`unsupported aggregate conversion` because the decision-DAG projection treated +the shared labelled store like a closed product. Record patterns now project +through one shared `project_labelled_field` kernel with the accessor path +(`cc/lower/record/open.rs`): a literal label, `Find`, `Read`, then the common +payload recovery to the requested use type. The pattern compiler still +specializes closed records into their checked products — only labelled stores +are read by label. The kernel rejects a pattern whose claimed representation is +not its labelled store, and never assumes a canonical index for unknown labels. + +Evidence: + +- The realize unit test + `open_record_patterns_find_their_field_label_in_the_labelled_store` proves a + case against an open row produces `Find`/`Read` assignments, no `ProductGet`, + and verifies as CC. +- Required-Wasmtime source runs + `a_record_pattern_selects_labels_across_distinct_open_rows` and + `nested_record_patterns_select_open_row_values` both exit 42 across differing + prefixes and nested open-row values. +- The official corpus cases `FieldPuns.purs`, `FieldConsPuns.purs` and + `ReservedWords.purs` — previously blocked on the closed-conversion path — + compile and finish with `Done` and exit zero. +- The full debug workspace passes 2,056 tests with `PSRS_REQUIRE_WASMTIME=1`; + formatting, source layout and Clippy with warnings denied remain clean. + Evidence: `/tmp/psrs-open-record-2026-10-17-pattern-workspace.log`. + +The runtime board was re-measured **279/340** (25 FFI groups excluded) with +release suite `7c9f8839dbd7cf73d63322e90ca2eefd5a904551c63cf73b76bb77b64673e2eb` +(results in `/tmp/psrs-open-record-2026-10-17-runtime/summary.json`; the 276 +control-repair checkpoint copy is +`/tmp/psrs-open-record-2026-10-17-runtime-checkpoint-276/`). Exactly the three +pun-blocking roots newly pass and no failure changed reason. + +Pattern-driven tail binding (`..rest`) remains unsupported at the surface. + +## Open-row update slice: 2026-10-17 (later still the same day) + +Open rows used to reject every record update with +`record representation has no canonical field labels`: the closed path names +canonical product indices, which a labelled store does not provide. The record +owner gains an `Update` operation (`cc/record/mod.rs`): CC lowering +(`cc/lower/record/open.rs`) lowers the base record once, then lowers every +update expression exactly once in source order, erasing each replacement +payload through the common planner into a private slot keyed by its literal +label; a duplicate label inside one update is rejected at its source position. +MIR lowering (`mir/lower/aggregate/record/operations.rs`) scans each +replacement label in the base's runtime sequence, traps when a label is absent +from the store, clones the value array once, patches the found slots, and +reuses the unchanged label sequence in a fresh header. The input store is +never written, the complete label/value sequence including the unknown tail is +retained, and closed-row updates keep their specialized product path. + +Evidence: + +- Direct CC/MIR/GC runs: `record_update_replaces_one_label_in_fresh_storage_and_keeps_the_rest` + packs, updates and unpacks with the unchanged fields and the replaced value; + `record_update_traps_on_a_label_missing_from_the_runtime_store` proves the + honest trap; `forged_record_update_operands_fail_at_the_public_cc_boundary` + rejects swapped and reused operands at the CC verifier. +- Required-Wasmtime source runs: source-order multi-label updates + (`open_row_updates_replace_labels_in_source_order_and_keep_the_tail`), a + type-changing field over a retained tail + (`a_type_changing_open_row_update_keeps_a_string_in_its_unknown_tail`), and + a case-branch update against the open-row scrutinee + (`a_case_branch_updates_its_open_row_scrutinee`) all exit 42. +- The official roots `ObjectUpdate.purs`, `ObjectUpdate2.purs`, + `ObjectUpdater.purs`, `NestedRecordUpdate.purs`, + `NestedRecordUpdateWildcards.purs` and the derived-Functor `2616.purs` + compile and finish correctly under the scoreboard conditions. +- The full debug workspace passes 2,062 tests with the required Wasmtime + baseline; formatting, source layout and Clippy with warnings denied remain + clean. Evidence: `/tmp/psrs-open-record-2026-10-17-update-workspace.log`. + +The runtime board lifts to **282/340** (25 FFI groups excluded) on release +suite `8585a60b24a8a02eb8b5c132c596359422492e224f6199a133fefd2364476337` +(decomposition in +`/tmp/psrs-open-record-2026-10-17-runtime-281/summary.json`): exactly +`ObjectUpdate.purs`, `NestedRecordUpdateWildcards.purs` and `2616.purs` leave +the failing set. `RowPolyInstanceContext.purs` still fails to typecheck; its +undetermined-variable pair prints in either order across repeated runs of the +same binary, so that presentation order is not a stable result. Frontend +boards are unchanged; the slice changed only backend lowering and its tests. + +OR-08 target bindings, OR-09 library record algorithms and the remaining +OR-11 rejection surfaces stay open. + +At this update checkpoint, the standard-library package was `6fcef6cc`; its +official sources had not been rewritten, and `Record.Unsafe` foreigns still +reported missing target support. The subsequent OR-09 library work is tracked +separately. Do not close #124 or declare the open-record topic complete on +these paths. + +## Checked record-storage primitive slice: 2026-10-17 + +`psrs-runtime` now owns source contracts for `psrs:record-storage#length`, +`key_at`, `payload_at` and `build`. The descriptor includes source parameter +and result types and the argument positions projected to the shared +`cc::RecordOperation` owner. P8 checks the checked Core source signature before +erasure, resolving bare and applied source types to a positively recorded +ordinary constructorless data carrier for `Slot`; missing, conflicting +ordinary/opaque/newtype evidence and constructor-bearing carriers reject. P9 +retains provider identity through CC +and validates that each operation matches its checked export. Closed product +record arguments and results use the ordinary shared pack/unpack conversion. +These bindings lower to the existing record kernels through helper functions; +they do not create another physical record schema or codec. P9 checks the +preserved provider export, operation and physical signature; it trusts the P8 +producer's checked source scheme and does not reconstruct `Slot` identity from +the erased arrays. + +`build` copies both source arrays before constructing the store. It checks +nonnegative/equal lengths, recovers each label from the source array's erased +element storage, rejects null labels and slots, enforces strictly increasing +Unicode-scalar label order, and returns a fresh `(labels, values)` store. The +source wrapper still owns searches and mutation algorithms. + +Evidence: + +- Final-tree, required-Wasmtime source tests in + `psrs-driver::tests::record_storage` pass 6/6 with + `PSRS_REQUIRE_WASMTIME=1`: five execute generated record operations + (including an imported applied-carrier `Cell Int` slot), and + `record_storage_validates_unused_source_contracts_before_erasure` rejects + unused bad source signatures at P8. The executions cover imported renamed ordinary `Cell`, + higher-order length, key and payload reads, nested and callable slots, + mutation of both original arrays after `build`, closed-record signatures, + empty storage, Unicode and prefix ordering, duplicate/unsorted labels, + unequal input lengths, and negative/out-of-range reads. +- Direct GC/MIR tests in + `mir::gc_tests::record::build` pass 2/2 direct executions with required + Wasmtime. The checked provider path builds through the shared owner; two + malformed MIR executions inject a null into each input array and verify the + label cast and payload checks trap. +- Backend source-contract tests prove valid ordinary carrier evidence and + reject missing metadata, ordinary/opaque and ordinary/newtype conflicts, and + constructor-bearing data. CC rejects providerless `Build` evidence, the + shared projection test covers each export/operation pair and rejects a + mismatched provider operation, and six P9 provider-boundary rejections cover + the provider registry: a valid base passes both checked stages, while a + second registered provider, a missing runtime binding, an export for another + operation, a direct-call bypass and a noncanonical external signature all + reject. All of this is final-tree evidence in the 2,084-test workspace run. +- An independent three-module imported fixture passed the library runner and + exited 42 after mutating its original labels and payload arrays. The + comparable diagnosis cohort is `/tmp/psrs-open-record-or08-independent-final.json` + and `/tmp/psrs-open-record-or08-independent-compare.json`; runtime output is + `/tmp/psrs-open-record-or08-independent-run-fixed/run.json`. + +The checked primitive contract and MIR slice is implemented. OR-09 library +wrappers and algorithms, remaining OR-11 rejection surfaces, and complete +integration checks remain open; this does not close the open-record topic. + +## Library record algorithms and unsafe wrappers: 2026-10-17 + +Package `e556bf2f8cb377c049d2ff0a5a97b484c7b36bc5` supplies the OR-09 +implementation at fingerprint `fnv1a64-v1:7d96f1c44d8d52cc`. Its clean +checkout is `/tmp/psrs-open-record-stdlib-or09`; the compiler lock retains +`../psrs-stdlib`, so use +`PSRS_STDLIB_ROOT=/tmp/psrs-open-record-stdlib-or09` for local compiler +commands. `Record.Unsafe` keeps the four official exports and signatures as +ordinary wrappers; `PSRS.Record` owns the search, insertion, deletion and +replacement algorithms and calls the checked `psrs:record-storage` operations. + +The final vendoring audit covers 50 packages and 309 modules, with no upstream +modules absent from the vendor (`/tmp/psrs-open-record-or09-evidence/final-audit/`). +The library's Node conformance suite passes 13/13. Its independent target +oracle report is +`/tmp/psrs-open-record-or09-evidence/oracle-edge-final/record-unsafe.json`: +the common value behavior is accepted; empty-record, missing-key and +inherited-key get cases produce the expected target traps; checks for updates, +deletes, callbacks, nested arrays and Unicode labels pass. The explicit target +differences are that Wasm has no JavaScript `undefined` result or prototype +lookup and treats `__proto__` as an ordinary own key. The report binds this +evidence to the package fingerprint and CLI SHA-256 +`d91a5b9943b871e4a90997ce0f0fde2123336aa248a7ca12272cd690425dceac`. + +This covers OR-09 and part of OR-10, not all unsafe-input and integration +surfaces. Broader malformed-store and ABI interactions, remaining OR-11 +rejection operations, OR-07/OR-12 combinations, and OR-13's full value-sensitive +boards remain open. Keep #124 In Progress until all required slices and final +boards are verified. + +## Final validation and board: 2026-10-17 + +The final checkout state carries the checked record primitives, the new +package, all source-layout repairs and the applied-carrier/P9 tests together. +Validation at this tree: formatting and source layout pass, Clippy passes with +warnings denied, and the release suite binary rebuilds with SHA-256 +`37bcdd007466f2ed91f2cbd06962c584bcdd00caef10f73465a5070f36d1f4ee`. The full +debug workspace passes 2,084 tests, failing zero, ignoring five, with +`PSRS_REQUIRE_WASMTIME=1` and `PSRS_STDLIB_ROOT=/tmp/psrs-open-record-stdlib-or09` +(`/tmp/psrs-or08-final-tree-workspace.log`). + +The runtime board was re-measured over all 365 program roots with the same +per-root worker pattern: **284/340** with all exits zero and 25 FFI groups +excluded (per-program rows in +`/tmp/psrs-open-record-or08-or09-runtime/summary.json`). Exactly +`Deriving.purs` and `TCOFloated.purs` leave the failing set — the two +`Record.Unsafe.unsafeGet` blockers — and no other failure changed state. The +56 non-agreeing roots split as 31 P5 typecheck, 11 P5 kind check, 5 P8 +closure/linking (3 closure capture, 1 recursive local binding, and the +`Test.Assert.checkThrows`-blocked `TCOMutRec.purs`), 5 P3 resolve, 2 P0 lex +(DEC-16), one 10-second runtime deadline, and the bounded BigFunction +compilation, which reached the measurement cap near the 2 GiB guard before a +diagnostic. `Test.Assert.checkThrows` remains the one record-adjacent foreign +without a target contract. The topic is not closed; #124 stays In Progress. + +## Shared coverage / Partial follow-through: 2026-10-17 + +The [handoff's final resumption](open-records-handoff-2026-10-17.md#partial-exhaustiveness-follow-through) +records completion of the blocked checker integration. Type checking and CC now +consume one usefulness engine. Nested pattern types are resolved after branch +unification; inferred and declared `Partial` constraints share the existing +elaboration convention; annotated non-exhaustive cases report at P5 with a +witness. Transparent newtype cycles such as official `Data.Void` retain erased +storage without recursive layout expansion. This work edits no package source. + +The final release runtime board is 287/340, up from 284/340 without regressions. +`Let`, `ShadowedTCOLet`, and `Where` now execute. L5 is 81/97 (`NoInstanceFound` +52/53), up from 77/97 (48/53), and L1–L4 stay unchanged. The full measurement, +resource-bound outcome, and artifact identity are recorded in +[the program scoreboard](../runtime-program-scoreboard.md#shared-coverage-and-partial-recheck-2026-10-17). +This is progress on the topic; OR-07 and OR-10–OR-14 retain their remaining +obligations rather than inheriting completion from the compiler-wide board. + +Final workspace validation for this follow-through uses the locked package via +`PSRS_STDLIB_ROOT=/tmp/psrs-open-record-stdlib-or09` and required Wasmtime: +**2,093 passed / 0 failed / 5 ignored**, with all ignored scoreboard groups +exercised separately. Formatting, source layout and strict full-workspace +Clippy pass. Evidence: `/tmp/psrs-partial-workspace-final.log` and +`/tmp/psrs-partial-clippy-accepted.log`. The final diff and design review for +this slice are complete; that does not close the broader topic obligations. + +## Generic local rows and nested ABI follow-through: 2026-10-18 + +P8 previously invoked a mandatory finite local-row preparation before CC layout, +even though the general record store already supports generic closures. That +preparation and its expression-instantiation-only helper have been removed. +Normal declaration specialization remains an optional Core optimization. The +unoptimized source-to-CC regression proves that two closed calls share one +captured generic reader and executes the emitted module with exit 42. Restoring +the old preparation makes that structural assertion fail (zero generic readers), +so an optimized successful execution alone cannot satisfy this regression. + +Focused evidence, with required Wasmtime: + +- `/tmp/psrs-or07-before-local.log` and + `/tmp/psrs-or07-without-local-preparation.log`: comparable existing captured + local-row execution before and after removing preparation. +- `/tmp/psrs-or07-specialization-negative-control.log`: expected failure with + the old preparation restored; `/tmp/psrs-or07-all-source.log`: 17/17 current + source tests, including four new composition regressions. +- `/tmp/psrs-or11-operand-matrix.log`: all five read/update operation contracts + checked at the public CC verifier, with independently specified use types. +- `/tmp/psrs-or10-malformed-store.log`: all five read/update operations execute + a valid control and trap on a malformed runtime store, with no subsequent + unpack that could mask a missing operation-local check (ten executions). +- `/tmp/psrs-or12-timestamp-roundtrip.log`: actual nested timestamp values + survive canonical ABI input, parameterized ADT/Array transport, open-row + update and canonical ABI output; host metadata verifies the latter. + +This removes OR-07's correctness prerequisite and supplies the previously +missing read/update rejection and nested ABI executions. The entire topic +still requires the current-tree workspace/board results and final integrated +review; earlier checkpoints remain historical evidence. + +The library-owned Node conformance suite also passes 13/13 at this continuation +(`/tmp/psrs-or07-node-conformance.log`). The independent `Record.Unsafe` oracle +was rerun against the current debug CLI and accepts normal values and the +empty/missing/inherited get traps: +`/tmp/psrs-or07-record-oracle/record-unsafe.json`. The package is unchanged. +The rebuilt release frontend boards retain L1 904/908, L2 72/72 (passing +resolution 403/413), L3 39/48, L4 39/50 and L5 81/97 (`NoInstanceFound` 52/53): +`/tmp/psrs-or07-frontboards.log`. + +The current release runtime remeasurement is **287/340**, with the same success +set and no regressions or recoveries relative to the shared-coverage checkpoint. +All 364 ordinary roots complete; 25 FFI groups are excluded. BigFunction is +measured separately and stops at the 2 GiB guard after 27.89 seconds, with peak +RSS 2,141,824 KiB. It remains a non-agreeing root; this continuation does not +claim its resource use is fixed. Evidence: +`/tmp/psrs-or07-runtime/{summary.json,results.jsonl,big-function-checked.json}`; +release suite SHA-256 +`52a9b77e706fd2030ed8c235c75f3a313ee118a18de0b49e4be21eb375e03df1`. + +Final workspace validation at this continuation passes **2,100 tests, zero +failures and five ignored** (`/tmp/psrs-or07-workspace.log`), with +`PSRS_STDLIB_ROOT=/tmp/psrs-open-record-stdlib-or09` and +`PSRS_REQUIRE_WASMTIME=1`. All five ignored scoreboard groups are exercised +separately as recorded above. The seven new tests are four open-row composition +programs, one nested ABI program, one public CC rejection matrix and one direct +GC runtime-content matrix; no prior test was removed. Formatting and strict +full-workspace Clippy pass (`/tmp/psrs-or07-fmt.log`, +`/tmp/psrs-or07-clippy.log`); the slice's final diff and owning design were +reviewed together. The complete topic remains In Progress pending the +requirement-by-requirement integrated review recorded in the handoff. + +## Integrated topic acceptance review: 2026-10-18 + +Every requirement row is now audited against the current tree, which is commit +`e331f9ba` with only driver tests and documentation added since. Each row's +named producer, verifier, direct-IR test, and executed runtime evidence was +confirmed to exist and re-run with required Wasmtime: + +- `tests::records` 45/45 (38 prior plus the seven new interaction regressions + below), `tests::record_storage` 6/6, backend record-schema/plan/owner and + malformed-input tests 77/77, the public CC operand matrix 15/15, and + `tests::wasi` 152/152 including the nested timestamp round trip. +- The library Node conformance suite passes 13/13 + (`/tmp/psrs-or0405-node-conformance.log`), including the pinned-source + `Record.Unsafe` oracle comparisons. +- The design document's invariant list maps one to one onto these row owners; + no invariant lacks a producer or a required runtime execution. + +Seven interaction regressions were added under +`tests::records::open_rows::interactions` to resolve the remaining OR-04/OR-05 +combination-coverage statements. All pass on the current tree; none required a +compiler repair: + +1. a record passes through a bare `forall a. a -> a` erased slot and reopens at + a later open-row use, proving the label/value sequence survives fully + polymorphic transport; +2. record patterns nest inside constructor scrutinees and select labels from + two different tails; +3. two record-pattern columns of one lambda each select their own label; +4. a wildcard field label neither binds nor consumes the unknown tail; +5. a callable field extracted by a pattern applies immediately; +6. a guard after a record pattern reads the pattern-bound label; +7. one value recovered from an erased ADT slot serves two different use types + without a second pack. + +OR-10's final interaction review, OR-11's final rejection-surface review, and +OR-12's final ABI review are resolved by the reruns above; their rows drop the +remaining clauses. Full workspace validation at this tree passes **2,107 tests, +zero failures and five ignored** (`/tmp/psrs-or0405-workspace-full.log`), +formatting and strict full-workspace Clippy pass (`/tmp/psrs-or0405-clippy.log`), +and the release suite rebuild for this review is SHA-256 +`52a9b77e706fd2030ed8c235c75f3a313ee118a18de0b49e4be21eb375e03df1` — the same +artifact identity as the e331f9ba measurement, because the review's library +changes are `cfg(test)`-only and release suite sources are unchanged. + +The fresh board re-measurement on this tree, using the same suite binary and +the standard worker pattern (3 workers, 90 s cap, 2 GiB RSS guard), reaches +**287/340** with the identical 52-case failure list, 25 excluded FFI groups, +and no incomplete worker besides `BigFunction.purs` +(`/tmp/psrs-or0405-runtime/summary.json`). BigFunction again stops at the RSS +guard, at 33.39 seconds with peak RSS 2,137,584 KiB — slower wall clock than +the 27.89-second e331f9ba run on the same identical compiler, which is +run-to-run variation rather than a changed property of the compiler. The +board, its success set, and every failed root's first blocking stage are +unchanged from the shared-coverage checkpoint, so the gate row and scoreboard +standings stand as recorded. + +With the interaction regressions landed and every row's evidence re-executed, +all fourteen requirement rows are verified against the current tree. The +remaining open obligations are the compiler-wide limitations tracked outside +this checklist: `BigFunction.purs`'s resource bound, the missing +`Test.Assert.checkThrows` target contract, and the ten-second +runtime timeout in `2288.purs`. The execution-checklist obligations for this +topic are complete; #124 can be closed with the board evidence above. + diff --git a/docs/implementation/backend/polymorphic-command-runner.md b/docs/implementation/backend/polymorphic-command-runner.md new file mode 100644 index 00000000..e6f35b8b --- /dev/null +++ b/docs/implementation/backend/polymorphic-command-runner.md @@ -0,0 +1,97 @@ +# Polymorphic command runner + +**Design:** [Library-owned effects](../../design/backend/fp/library-owned-effects.md) +and [Native Effect/ST](../../design/backend/fp/native-effect-and-st-handlers.md). + +## Problem and ownership + +The unchanged official `passing/Sequence.purs` selects +`main :: Effect (List Unit)`. The locked package's runner accepted only +`Effect Unit`, so compilation stopped at P7 command normalization. A command +must execute the action and ignore its returned value; it need not restrict +that value to Unit. + +The driver still resolves exactly the configured source runner and selects the +entry. Core owns checked application construction. The library owns execution +and result-discarding policy. No compiler stage recognizes an Effect name or +invents an Effect operation. + +## Implementation contract + +`command::normalize_entry` checks the runner's complete instance at +`entry_type -> Int` with `Module::checked_instantiation`, the same type relation +used by Core global verification. It does not implement a second type matcher. +Only declared quantified variables may instantiate; repeated variables have one +consistent replacement and nested scopes retain their normal rules. + +The runner's declared result must be Int and the selected entry must remain +closed. Monomorphic runners and direct Int entries retain their behavior. +Invalid runner identities and input/result mismatches remain explicit errors. +New type nodes and the generated application are built on a candidate module; +failed validation does not publish a modified type table or entry. + +The target-owned `PSRS.Command.run` becomes +`forall a. Effect a -> Int`. Its body still invokes `unsafePerformEffect` once +and returns zero after normal completion. Traps propagate. All 279 non-target +library source entries are byte-identical to the previous locked package. +The helper's target provenance hash and rationale are updated in the independent +package; official pure definitions and public Effect APIs are unchanged. + +## Evidence + +- `psrs-cli --test command_runner`: six tests pass under mandatory Wasmtime. + They cover ordinary unrelated newtypes, direct Int entries, String/record/ + function results, repeated-variable inconsistency, wrong runner result, + identity errors, and report/check-only paths. +- `Sequence.purs`: baseline P7 rejection at `fe6dd78f`; after the compiler and + target-helper change it compiles, validates, prints exactly `Done\n`, and + exits zero through the library-owned conformance runner. +- The independent package adds `CommandResult` and `CommandResultTrap` to its + native conformance batch. They check one visible action, ignored function + results, and trap propagation before a record result can be returned. +- Library tooling: `npm test`, 12 passed. + +The four focused native driver tests also pass under mandatory Wasmtime. The +old test rejecting `Effect Int` was replaced by acceptance/execution evidence +and rejection of an unrelated Array carrier; the runner still accepts only an +instance of its declared input type. + +The Native Effect batch has 14/15 accepted cases. Algebraic exceeded its +240-second compilation limit and passed an isolated retry with a 600-second +budget: 382.66 seconds to compile, 0.088 seconds to execute, exact output and +exit 42. The original failed batch is retained unchanged. Together those runs +provide executed evidence for all 15 cases on fingerprint +`fnv1a64-v1:4b052f09c153208f`; they are not reported as a green single batch. + +The complete runtime board measured 273/340 program groups under this +command repair. The +[runtime accounting record](../runtime-program-scoreboard.md) keeps its scope +correction and remaining 67 outcomes separate from this command repair, and the +D-04 gate table carries the current number. + +The independent package commit is +`50c26d67a78a6b25a9915b28fef981bc178cd767`, on local branch +`fix/polymorphic-command-runner` in `/tmp/psrs-command-runner-stdlib`. The +compiler lock at that measurement pins that revision and fingerprint. The +subsequent Symbol slice pins its descendant `6fcef6cc`; see the +[contextual application record](../frontend/contextual-application-results.md). Verification selects that +worktree explicitly with `PSRS_STDLIB_ROOT`; the sibling package's existing +checkout is preserved. The package commit has not been pushed, and no remote +CI result is claimed by these local runs. + +Final Rust validation on 2026-10-17: + +```sh +cargo fmt --all --check +PSRS_REQUIRE_WASMTIME=1 \ + PSRS_STDLIB_ROOT=/tmp/psrs-command-runner-stdlib cargo test --workspace +cargo clippy --workspace --all-targets -- -D warnings +``` + +All commands pass. The workspace reports 2,034 passed and five ignored +scoreboards; those boards were executed separately with the release harness. +The final workspace log is `/tmp/psrs-polymorphic-runner-workspace-final.log`. +Earlier failures are retained: one run observed the sibling checkout switch, +and a later run exposed the obsolete Effect-Int rejection assertion. The final +run uses the isolated package and the corrected acceptance/rejection tests. + diff --git a/docs/implementation/frontend/contextual-application-results.md b/docs/implementation/frontend/contextual-application-results.md new file mode 100644 index 00000000..8a6c40cb --- /dev/null +++ b/docs/implementation/frontend/contextual-application-results.md @@ -0,0 +1,105 @@ +# Contextual application results and Symbol reification + +**Design:** [Type inference](../../design/frontend/type-system/type-inference.md). + +## Baseline and owner + +The public `Data.Symbol.reifySymbol` callback may use its locally supplied +`IsSymbol` evidence, but cannot return a proxy at an arbitrary fixed symbol. +The unchanged negative source + +```purescript +bad :: String -> Proxy "fixed" +bad value = Symbol.reifySymbol value (\proxy -> proxy) +``` + +was rejected as `EscapedSkolem`, while the pinned official `purs` rejects it as +`TypesDoNotUnify`. Before checking the callback, P5 synthesized an unknown +result for the application without using the written `Proxy "fixed"` context. +Binding that unknown to the callback's rigid symbol triggered the shared escape +guard before the concrete result mismatch could be checked. + +Application checking now constrains a callable's result against a monomorphic +expected result before checking the argument. Result instantiation and wanted +dictionary evidence use the existing scheme operations; evidence is applied to +the completed application after the argument check. Synthesis without a result +context is unchanged. Contexts containing universal or qualified binders retain +synthesis followed by subsumption, preserving the rejection of impredicative +constructor instantiation. The shared skolem-escape binding rule is unchanged. + +This belongs to ordinary bidirectional application checking, independently of +Symbol names, library paths, or backend representation. The minimal shared +fixture uses only `(forall a. a -> r) -> r` and an `Int` result. + +## Focused evidence + +- The shared rank-N acceptance/rejection battery and mandatory Wasmtime runs + pass, including a constant callback result and a returned constrained value + that needs dictionary evidence. Existing impredicative rejection and genuine + inferred-parameter skolem escape remain covered. +- Two explicit `purs` differential tests pass: the complete shared battery and + the minimal rigid-result diagnostic comparison. +- The independent Symbol oracle passes forty value observations, dynamic input, + one callback invocation, nested/captured dictionaries, record/closure results, + strict construction and repeated execution of a returned action, and failure + propagation. Missing evidence and symbol escape agree with official codes. +- L4 remains 39/50 and L5 remains 77/97 on the same official failing selections + before and after the checker change. L3 remains 39/48. + +The before/after diagnosis reports are `/tmp/psrs-symbol-escape-before.json` and +`/tmp/psrs-symbol-escape-after.json`; the library report is +`/tmp/psrs-symbol-reification-verified/symbol.json`. The independent package +commit is `6fcef6ccbab1f3459af2573bbef4ae25b86852d4`, fingerprint +`fnv1a64-v1:5fcbb57525c63eb1`, in `/tmp/psrs-symbol-reification-stdlib`. +The package preserves every original pure `Data.Symbol` definition and replaces +only its private JavaScript identity foreign slot with the existing typed +`Unsafe.Coerce` interface. Its public-source comparison checks the full exact +diff against pinned prelude v6.0.1. The local package commit is not pushed. + +The diagnosis comparison confirms the same cohort, input and observed +environment, with the P5 category changing from `EscapedSkolem` to +`TypesDoNotUnify`. It reports no difference in the observed pass prefix; +canonical artifact-content comparison and build-toolchain compatibility remain +unavailable. The comparison is `/tmp/psrs-symbol-escape-comparison.log`. + +The pre-existing Symbol reflection differential now uses an unchanged pinned +`Symbol.purs` fixture alongside its official `Symbol.js`, rather than feeding +the target-adapted source to `purs` without its new target import. Both files +come from prelude commit `f4cad0ae8106185c9ab407f43cf9abf05c256af4`, paths +`src/Data/Symbol.purs` and `src/Data/Symbol.js`. The PureScript SHA-256 is +`8302d378edfcd58fbe1aadcf95c22f8637533de188b99ca2a0e43147e8957ec4`. +Target execution and full source fidelity remain independently checked by the +library oracle. + +## Integration validation + +The final 2026-10-17 run uses the isolated package above and required Wasmtime: + +```sh +PSRS_REQUIRE_WASMTIME=1 PURESCRIPT_REPO=/Users/biu/Projects/purescript \ + PSRS_STDLIB_ROOT=/tmp/psrs-symbol-reification-stdlib cargo test --workspace +cargo fmt --all --check +PSRS_STDLIB_ROOT=/tmp/psrs-symbol-reification-stdlib \ + cargo clippy --workspace --all-targets -- -D warnings +``` + +All pass: 2,037 tests passed, zero failed, five ignored scoreboards executed +separately. The final workspace log is +`/tmp/psrs-symbol-context-workspace-final.log`; all twenty official differential +tests execute and pass. The earlier workspace failure is retained in +`/tmp/psrs-symbol-context-workspace.log`: it expected `EscapedSkolem` for an ST +action checked against a concrete `Int`. Official differential evidence now +establishes `TypesDoNotUnify` for that mismatch, while a separate inferred-result +ST escape remains rejected with `EscapedSkolem`. The nine region tests pass. +The temporary missing-import failure in the old Symbol differential is retained +in `/tmp/psrs-symbol-context-rank-and-upstream-final.log`; its pinned-source fix +is included in the final green workspace run. + +The release runtime board measured 273/340 at this change's acceptance, with +exactly the same 67 failure paths and reasons; the current number lives in the +[runtime recheck](../runtime-program-scoreboard.md#symbol-and-contextual-checking-integration-recheck) +and the D-04 gate table. All L1–L5 boards retain their prior counts. + +This API acceptance does not close #124 or establish full standard-library +coverage. `Test.Assert.checkThrows` remains blocked on exception/trap recovery +outside the default target capability profile; #95 is marked Blocked. diff --git a/docs/implementation/runtime-program-scoreboard.md b/docs/implementation/runtime-program-scoreboard.md new file mode 100644 index 00000000..ea5c000e --- /dev/null +++ b/docs/implementation/runtime-program-scoreboard.md @@ -0,0 +1,336 @@ +# Runtime scoreboard program units + +**Scope:** L6/M7 runtime measurement; frontend scoreboards retain their per-file +units. This corrects harness accounting, not compiler or library support. + +## Contract + +The pinned official runner groups a category-root `.purs` file with its +same-stem support directory. See `tests/TestUtils.hs::getTestFiles` and +`tests/TestCompiler.hs::assertCompiles` in the reference PureScript checkout. +The latter compiles the group, runs `Main.main`, and requires the final output +line to be `Done`. + +The runtime board follows those program groups. It retains all support sources +as compilation inputs, but never runs them again as independent commands. +Selection does not depend on successful parsing or discovering `main`, so a +malformed root or a missing entry remains a failure. Filters and limits apply +to program roots, in that order. `PSRS_SUITE_PROGRESS=1` prints each root before +compiling it so a long run identifies its current program. A foreign declaration or sibling JavaScript +file in any program source excludes the whole program under the existing FFI +policy. Compiler-owned target bindings in the trusted library are not corpus +FFI exclusions. + +The vendored `passing` directory contains 439 source files: 365 program roots +and 74 support files. These are inventory units, not interchangeable acceptance +denominators. Frontend boards continue to inspect individual source files. +Every support source in this pinned inventory has a same-stem program root; +there are no directory-only program groups. + +This board still records output and exit status without requiring `Done` or +comparing output goldens. Compilation, Wasm validation, and completion without +a trap do not establish full observable agreement with upstream. + +## Baseline and owner + +Starting checkout: clean `master` at `fe6dd78f`. The historical per-file board +at `886cd82c` recorded 273/413 and 66 failures attributed to +`Data.Symbol.unsafeCoerce`. All 66 paths are support modules with no `main`. +Compiling those modules alone retains every library declaration because there +is no executable entry from which to prune unreachable imports. + +The small baseline `2018/A.purs` fails at P8 library linking on the unchanged +compiler; the enclosing `2018.purs` supplies the actual command and its support +sources. Standalone `Data.Symbol.reifySymbol` target support remains unproved +and is not implemented by this correction. No library source, primitive +registry, reachability rule, or package lock changes. + +## Verification + +Regression coverage is in `suite/corpus/tests.rs`: a support module supplies a +value to its parent program without becoming a command; malformed and +entry-less roots remain selected; filtering precedes limiting; support foreign +declarations and JavaScript files exclude their program. + +The first attempted full run was invalidated when the sibling standard-library +checkout changed branches. Its worker failures are not compiler measurements. +Subsequent runs use an archive of locked commit +`4c73aab52226e8f5e7b1f17052f1a19d717a472e`, selected explicitly with +`PSRS_STDLIB_ROOT`; the required content fingerprint remains +`fnv1a64-v1:ad19620ada1d2ecf`. + +## Full remeasurement: 2026-10-17 + +Compiler: `fe6dd78f` plus the checked command-runner and program-grouping +changes. Package: `50c26d67a78a6b25a9915b28fef981bc178cd767`, fingerprint +`fnv1a64-v1:4b052f09c153208f`. Runtime: Wasmtime 49.0.3. The measured release +suite binary SHA-256 is +`34e755d1b36825cb5fe9b724afbb4c33ba5428bcec1c9114ecd78ca00efa7263`. +The debug full-workspace check is a separate validation run. + +Two disjoint cohorts of 183 and 181 roots completed using the same source +bytes and support directories. The separately bounded `BigFunction.purs` +completes the 365-root inventory; the aggregation checks complete membership +without duplicates. There are **25 excluded FFI programs**, **340 in-scope +programs**, and **273 completions**, all with exit zero and no trap. + +| Remaining outcome | Programs | +| --- | ---: | +| P5 typecheck | 31 | +| P5 kind check | 11 | +| P8 closure conversion | 13 | +| P3 resolve | 5 | +| P0 lex, DEC-16 intentional differences | 2 | +| Unsupported library foreign | 3 | +| Runtime timeout, 10 seconds | 1 | +| Compiler resource bound | 1 | + +The foreign failures are `Record.Unsafe.unsafeGet` in `Deriving.purs` and +`TCOFloated.purs`, and `Test.Assert.checkThrows` in `TCOMutRec.purs`. +`2288.purs` times out during execution. BigFunction exceeds the 2 GiB RSS bound +after 17.54 seconds before returning a diagnostic or publishing a runnable artifact; it is an +unverified compilation, not a guest trap. No requirement is closed by stopping +that process. Its debug probe separately reached a 30-second compilation limit. + +The previous 273/413 is historical per-file accounting. All 66 Symbol failures +are support modules. Program-wide FFI exclusion additionally removes the +previously completing `ForeignKind.purs` because its support source declares +foreign types. The actual compiler/library repair executes unchanged +`Sequence.purs`; the unchanged numerator therefore does not mean unchanged +behavior. These are different cohorts, not 66 newly working programs. + +Commands use `PSRS_REQUIRE_WASMTIME=1`, +`PSRS_STDLIB_ROOT=/tmp/psrs-command-runner-stdlib`, and the release suite built +by `cargo test --release -p psrs-driver --test suite --no-run`. Each cohort +selects its staged corpus with `PURESCRIPT_REPO` and executes +`suite --ignored --exact runtime::l6_runtime_scoreboard --nocapture`. +`PSRS_SUITE_PROGRESS=1` records roots before compilation. BigFunction uses an +isolated worker with a 30-second process limit and a 2 GiB RSS guard. + +Local evidence: `/tmp/psrs-runtime-programs-release-0.log`, +`/tmp/psrs-runtime-programs-release-1.log`, +`/tmp/psrs-runtime-programs-release-big-function.json`, and +`/tmp/psrs-runtime-programs-acceptance/summary.json`. The adjacent source +manifest retains all 439 source hashes. Source hashes and suite identity in the +summary distinguish the measured compiler changes from their base. +Earlier incomplete debug cohorts were superseded and are not measurements. + +The four frontend boards were also re-run with the release suite, +`PSRS_ORACLE=annotations`, and `--ignored --skip runtime::l6_runtime_scoreboard +--nocapture`. L1 remains 904/908, L2 72/72 and passing resolution 403/413, +L3 39/48, L4 39/50, and L5 77/97. The source-file denominators are unchanged. + +The final debug workspace run with `PSRS_REQUIRE_WASMTIME=1` and the isolated +locked-content package passes: 2,034 passed, five ignored scoreboards (executed +separately above). Formatting, Clippy with warnings denied, and diff whitespace +checks pass. The focused corpus/runtime classifier tests report 14 passed. + +## Symbol and contextual-checking integration recheck + +The 2026-10-17 recheck uses package +`6fcef6ccbab1f3459af2573bbef4ae25b86852d4`, fingerprint +`fnv1a64-v1:5fcbb57525c63eb1`, and the same compiler base plus the +[contextual application check](frontend/contextual-application-results.md). +The release suite SHA-256 is +`f98043ae777bdaf544f2250f58a5c6c74f979a80f74110a8daf86700a4b0227a`. +Both disjoint cohorts finish: 136/170 and 137/169, excluding 13 and 12 FFI +programs. BigFunction again exceeds the 2 GiB RSS guard before a diagnostic, +after 20.25 seconds (60-second outer limit). The total remains 273/340, all +exiting zero, with the exact same 67 failure paths and reasons as above. +All 439 source hashes and all 365 program roots are checked during aggregation. + +Evidence is `/tmp/psrs-symbol-runtime-{0,1}.log`, +`/tmp/psrs-symbol-runtime-big-function.json` and +`/tmp/psrs-symbol-runtime-acceptance/summary.json`. All L1–L5 boards were also +rerun on this package and checker; their numbers remain unchanged. This new +package pin adds independently executed Symbol API coverage, with no claim of +recovering a program from the existing runtime corpus. + +## Open-record read/transport checkpoint recheck: 2026-10-17 + +Compiler `fe6dd78f` plus the open-record read/transport and +control-pair boundary changes recorded in +[the same-day handoff](backend/open-records-handoff-2026-10-17.md) and committed +with it, package +`6fcef6cc` / `fnv1a64-v1:5fcbb57525c63eb1`, Wasmtime 49.0.3, release suite +binary SHA-256 +`61d4dea7488511191630dcf2adb060f2de9d62701786893434b6b6f955212b7e`. Every +program root was measured with its own `PSRS_SUITE_FILTER` worker +(`PSRS_REQUIRE_WASMTIME=1`), three at a time; BigFunction ran under the +isolated 2 GiB RSS guard and stopped before a diagnostic. + +The board is **276/340** at the read/transport checkpoint: all completions exit +zero, 25 FFI groups are excluded, and the 64 non-agreeing roots carry the same +reasons as the pre-repair measurement besides the three newly passing programs +(`1807.purs`, `NestedRecordUpdate.purs`, `ObjectGetter.purs`). Their joint +reason profile is 31 P5 typecheck, 11 P5 kind check, 13 P8 closure conversion, +5 P3 resolve, 2 P0 lex (DEC-16), 3 missing library foreigns, one 10-second +runtime timeout, and the bounded BigFunction compilation. The read/transport +release suite binary is +`61d4dea7488511191630dcf2adb060f2de9d62701786893434b6b6f955212b7e`; the open-row +record-pattern slice then lifts the board to **279/340** with exactly the three +pun-blocking roots (`FieldPuns.purs`, `FieldConsPuns.purs`, +`ReservedWords.purs`) newly passing and no other reason changed, on release +suite binary +`7c9f8839dbd7cf73d63322e90ca2eefd5a904551c63cf73b76bb77b64673e2eb`, whose +decomposition is 31 P5 typecheck, 11 P5 kind check, 10 P8 closure conversion, +5 P3 resolve, 2 P0 lex, 3 missing foreigns, one timeout, and the bounded +BigFunction compilation. Evidence: +`/tmp/psrs-open-record-2026-10-17-runtime/summary.json` per program and +`/tmp/psrs-open-record-2026-10-17-boards.log` for the L1–L5 boards, whose +numbers are unchanged. The full debug workspace run at the pattern-slice tree +is 2,056 passed, 0 failed, 5 ignored +(`/tmp/psrs-open-record-2026-10-17-pattern-workspace.log`). + +## Open-row immutable-update recheck: 2026-10-17 + +Compiler `fe6dd78f` plus the open-record read/transport, record-pattern and +open-row immutable-update slices recorded in +[the same-day handoff](backend/open-records-handoff-2026-10-17.md) and +committed with it — package `6fcef6cc` / `fnv1a64-v1:5fcbb57525c63eb1`, +Wasmtime 49.0.3, release suite binary SHA-256 +`8585a60b24a8a02eb8b5c132c596359422492e224f6199a133fefd2364476337` — measured +with the same per-root `PSRS_SUITE_FILTER` worker arrangement, three workers, +`PSRS_REQUIRE_WASMTIME=1`, and the isolated 2 GiB RSS guard for BigFunction. + +The board is **282/340**: all completions exit zero, 25 FFI groups are +excluded, and the 58 non-agreeing roots carry the previous reasons besides the +three newly passing ones (`ObjectUpdate.purs` and +`NestedRecordUpdateWildcards.purs`, both previously rejected by absent +canonical record-update labels, and `2616.purs`, whose derived +`Functor (F r)` map writes its open-row field). The reason profile is 31 P5 +typecheck, 11 P5 kind check, 7 P8 closure conversion within the closure-linking +family (3 closure capture, 1 recursive local binding, 3 missing library +foreigns `Record.Unsafe.unsafeGet` 2 and `Test.Assert.checkThrows` 1), 5 P3 +resolve, 2 P0 lex (DEC-16), one 10-second runtime timeout, and the bounded +BigFunction compilation. One failing program (`RowPolyInstanceContext.purs`) +printed its undetermined-variable pair in both orders across repeated runs of +the same binary; the presentation order is run-to-run noise and the case fails +typechecking either way. Frontend boards were not rerun; the slice changed +only backend lowering and its tests. Evidence: +`/tmp/psrs-open-record-2026-10-17-runtime-281/summary.json` per program and +`/tmp/psrs-open-record-2026-10-17-update-workspace.log` for the full debug +workspace (2,062 passed, 0 failed, 5 ignored). + +## Checked record-storage recheck: 2026-10-17 + +Compiler `fe6dd78f` plus the earlier open-record slices and the checked +record-storage runtime primitives with official library record wrappers +recorded in [the open-records checklist](backend/open-records.md) and +[the same-day handoff](backend/open-records-handoff-2026-10-17.md) — package +`e556bf2f` / `fnv1a64-v1:7d96f1c44d8d52cc`, Wasmtime 49.0.3, release suite +binary SHA-256 +`37bcdd007466f2ed91f2cbd06962c584bcdd00caef10f73465a5070f36d1f4ee` — measured +with the same per-root `PSRS_SUITE_FILTER` worker arrangement, three workers, +`PSRS_REQUIRE_WASMTIME=1`, and a 90-second cap with an explicit RSS guard for +BigFunction. + +The board is **284/340**: all completions exit zero, 25 FFI groups are +excluded, and exactly the two roots blocked by the missing +`Record.Unsafe.unsafeGet` foreign (`Deriving.purs` and `TCOFloated.purs`) join +the agreeing set; no other failure changed state. The reason profile is 31 P5 +typecheck, 11 P5 kind check, 5 P8 closure/linking (3 closure capture, +1 recursive local binding, and `TCOMutRec.purs`, still blocked by the missing +`Test.Assert.checkThrows` foreign), 5 P3 resolve, 2 P0 lex (DEC-16), one +10-second runtime timeout, and the bounded BigFunction compilation, which +reached the measurement cap near the 2 GiB guard before a diagnostic. Evidence: +`/tmp/psrs-open-record-or08-or09-runtime/summary.json` per program and +`/tmp/psrs-or08-final-tree-workspace.log` for the full debug workspace at the +same tree (2,084 passed, 0 failed, 5 ignored, with +`PSRS_REQUIRE_WASMTIME=1`). + +## Shared coverage and Partial recheck: 2026-10-17 + +Compiler `7dd45e4b` plus the shared coverage/checker `Partial` slice and the +transparent-newtype-cycle repair, with the same clean package `e556bf2f` / +`fnv1a64-v1:7d96f1c44d8d52cc`, required Wasmtime 49.0.3 and release suite +SHA-256 `bab10d4c7480c7db64fce9dacba8c96c035f9e760a810679a9d2a750a59233a3`, measures **287/340**. +All successful programs exit zero. The 365 roots include 25 excluded FFI +groups; 74 support sources remain inputs. Relative to the preceding 284/340 +measurement, exactly `Let.purs`, `ShadowedTCOLet.purs`, and `Where.purs` recover, +and no agreeing program regresses. + +The remaining 53 are 31 P5 typecheck, 11 P5 kind check, 2 P8 closure/linking +(one recursive local binding and `TCOMutRec.purs`'s missing +`Test.Assert.checkThrows` foreign), 5 P3 resolve, 2 P0 lex, the existing +10-second `2288.purs` runtime timeout, and the bounded BigFunction compilation. +BigFunction times out at 90.89 seconds with peak RSS 1,485,792 KiB under the +90-second cap and 2 GiB guard. The three-worker run completed 364 roots; its +sandbox blocked `ps` when starting BigFunction's RSS check, so that unfinished +worker was stopped and BigFunction was rerun separately with the guard enabled. +The final manifest combines those 364 completed rows with that guarded result. + +Evidence: `/tmp/psrs-partial-final-runtime/summary.json`, `results.jsonl`, and +`big-function-checked.json`. The frontend boards were also rerun at this slice: +L1 904/908, L2 72/72 (passing resolution 403/413), L3 39/48, L4 39/50, +L5 **81/97** (`NoInstanceFound` **52/53**); see +`/tmp/psrs-partial-frontboards-final.log`. These are compile/code agreement and +exit-code measurements; they do not establish output-golden conformance. + +The same slice's full debug workspace passes 2,093 tests, zero failures and five +ignored scoreboards (run separately as above), with required Wasmtime and the +same package: `/tmp/psrs-partial-workspace-final.log`. Formatting, source layout, +and strict full-workspace Clippy also pass. + +## Generic local rows recheck: 2026-10-18 + +Compiler `7dd45e4b` plus the preserved shared-coverage slice and the removal of +P8's mandatory local-row specialization, using the same clean package +`e556bf2f` / `fnv1a64-v1:7d96f1c44d8d52cc` and required Wasmtime 49.0.3, +remeasures **287/340**. The agreeing set is unchanged: no regressions and no +recoveries. All successful programs exit zero. The same 365 roots, 74 support +inputs and 25 excluded FFI groups are retained. The release suite SHA-256 is +`52a9b77e706fd2030ed8c235c75f3a313ee118a18de0b49e4be21eb375e03df1`. + +The ordinary three-worker run completes 364 roots. BigFunction is separately +bounded by the same 90-second cap and 2 GiB RSS guard; it reaches the RSS guard +in 27.89 seconds, with observed peak 2,141,824 KiB. This differs from the previous +90.89-second timeout at 1,485,792 KiB, but both are incomplete compilation and +neither counts as runtime agreement. The other failures retain their prior +31 P5 typecheck, 11 P5 kind check, 2 P8, 5 P3 resolve, 2 P0 lex and one runtime +timeout split. No claim about BigFunction performance is made from the changed +preparation path. + +Evidence: `/tmp/psrs-or07-runtime/summary.json`, `results.jsonl` and +`big-function-checked.json`. The current library-owned `Record.Unsafe` JS/Wasm +oracle also accepts its normal value program and three expected-trap programs +(`/tmp/psrs-or07-record-oracle/record-unsafe.json`); Node conformance passes +13/13. The frontend boards remain L1 904/908, L2 72/72 (passing resolution +403/413), L3 39/48, L4 39/50 and L5 81/97 (`NoInstanceFound` 52/53): +`/tmp/psrs-or07-frontboards.log`. + +The matching final debug workspace passes **2,100 / 0 failed / 5 ignored**, +with required Wasmtime and the same development package +(`/tmp/psrs-or07-workspace.log`); the five ignored scoreboard groups execute +separately above. Formatting, source layout and strict workspace Clippy pass. +The seven new regressions cover generic local-row lowering, composed row +transport, per-operation CC/runtime rejection and actual nested ABI values. +This remains separate from whole-topic acceptance. + +## Integrated open-record review recheck: 2026-10-18 + +Commit `e331f9ba` with only `cfg(test)`-only driver tests and documentation +added since, the same clean package +`e556bf2f` / `fnv1a64-v1:7d96f1c44d8d52cc` and required Wasmtime 49.0.3, +re-measures the board with the worker pattern above: **287/340**. The release +suite binary is unchanged at SHA-256 +`52a9b77e706fd2030ed8c235c75f3a313ee118a18de0b49e4be21eb375e03df1` because no +compiler or suite source differs; the identity is re-established rather than +assumed. All 364 ordinary roots complete; 25 FFI groups remain excluded. + +The failing set is identical to the previous checkpoint — the same 31 P5 +typecheck, 11 P5 kind check, 2 P8, 5 P3 resolve, 2 P0 lex and one ten-second +runtime timeout in `2288.purs` — with no regressions and no recoveries. +BigFunction is separately bounded by the RSS guard again: it stops at +33.39 seconds with observed peak 2,137,584 KiB on this run, against +27.89 seconds / 2,141,824 KiB previously on the identical compiler. The +difference is run-to-run wall-clock variation, and no claim about BigFunction +performance is made from either run. + +Evidence: `/tmp/psrs-or0405-runtime/summary.json` and `results.jsonl`. The +debug workspace passes **2,107 / 0 failed / 5 ignored** +(`/tmp/psrs-or0405-workspace-full.log`); the seven additional tests are the +open-row interaction regressions recorded in the +[open-record checklist](open-records.md#integrated-topic-acceptance-review-2026-10-18). +With every requirement row re-executed and the board re-measured, the open-record +checklist is complete at commit `e331f9ba` plus this review. diff --git a/stdlib.lock.json b/stdlib.lock.json index 7f4c42f5..c000f9bc 100644 --- a/stdlib.lock.json +++ b/stdlib.lock.json @@ -1,6 +1,6 @@ { "schema_version": 1, "path": "../psrs-stdlib", - "revision": "4c73aab52226e8f5e7b1f17052f1a19d717a472e", - "source_fingerprint": "fnv1a64-v1:ad19620ada1d2ecf" + "revision": "e556bf2f8cb377c049d2ff0a5a97b484c7b36bc5", + "source_fingerprint": "fnv1a64-v1:7d96f1c44d8d52cc" }