diff --git a/src/v3/compiler/build.rs b/src/v3/compiler/build.rs index a03b0ee1ff6..e0918e824ac 100644 --- a/src/v3/compiler/build.rs +++ b/src/v3/compiler/build.rs @@ -436,7 +436,7 @@ fn emit_r1_gates_fixture(manifest_path: &Path, v3_dir: &Path) { /// **Hygiene:** `repeat_string` exists in the bootstrap bundle only via this excerpt — no /// hand-maintained duplicate of `render.dag` (INVARIANTS P2). Edit the marked region in /// `render.dag` only; this output is overwritten on `cargo build -p v3-compiler`. -/// Emit PB-0 cycle-4 host authority modules (former `EXPECTED_HAND_AUTHORED_NON_TEST` paths) from +/// Emit PB-0 host authority modules (former `EXPECTED_HAND_AUTHORED_NON_TEST` paths) from /// checked-in templates so `REGEN_OUTPUTS` / `GENERATED_FILES` stay the sole SG-0 partition /// authority (`sg0_v3_hand_authored_census`). /// @@ -464,6 +464,10 @@ fn emit_pb0_cycle4_host_authority_modules(repo_root: &Path) { ); let tmpl_dir = repo_root.join("tools/pb0_cycle4_emit_templates"); let specs: &[(&str, &str)] = &[ + ( + "cementing_dispatch.rs.in", + "src/v3/compiler/src/cementing_dispatch.rs", + ), ( "complexity_lattice.rs.in", "src/v3/compiler/src/complexity_lattice_generated.rs", @@ -472,6 +476,8 @@ fn emit_pb0_cycle4_host_authority_modules(repo_root: &Path) { "cost_basis_declaration.rs.in", "src/v3/compiler/src/cost_basis_declaration_generated.rs", ), + ("diagnostics.rs.in", "src/v3/compiler/src/diagnostics.rs"), + ("dimension.rs.in", "src/v3/compiler/src/dimension.rs"), ( "emit_collection_ops_method_contract.rs.in", "src/v3/compiler/src/emit/collection_ops_method_contract_generated.rs", @@ -488,10 +494,19 @@ fn emit_pb0_cycle4_host_authority_modules(repo_root: &Path) { "int_literal_ranges.rs.in", "src/v3/compiler/src/int_literal_ranges_generated.rs", ), + ("infer.rs.in", "src/v3/compiler/src/infer.rs"), ( "pb_method_template_projection.rs.in", "src/v3/compiler/src/pb_method_template_projection_generated.rs", ), + ( + "pipeline_authority.rs.in", + "src/v3/compiler/src/pipeline_authority.rs", + ), + ( + "post_emit_verifier.rs.in", + "src/v3/compiler/src/post_emit_verifier.rs", + ), ]; for (tmpl_name, rel_out) in specs { let src_path = tmpl_dir.join(tmpl_name); @@ -754,8 +769,9 @@ fn main() { "src/v3/compiler/src/bootstrap_generated.rs", "src/v3/compiler/src/bootstrap_generated_without_parse_surface.rs", "src/v3/compiler/src/bootstrap_std_generated.rs", - // PB-0 cycle-4: former hand `EXPECTED_HAND_AUTHORED_NON_TEST` modules — templates under + // PB-0: former hand `EXPECTED_HAND_AUTHORED_NON_TEST` modules — templates under // `tools/pb0_cycle4_emit_templates/*.rs.in`, emitted by `emit_pb0_cycle4_host_authority_modules`. + "src/v3/compiler/src/cementing_dispatch.rs", "src/v3/compiler/src/complexity_lattice_generated.rs", // SG-5 substrate / runtime-mirror projections, generated from // `src/v3/std/substrate.dag` + `src/v3/std/parse_surface.dag` @@ -773,10 +789,13 @@ fn main() { "src/v3/compiler/src/dag_lookup_generated.rs", "src/v3/compiler/src/dag_scalar_generated.rs", "src/v3/compiler/src/dag_value_body_generated.rs", + "src/v3/compiler/src/diagnostics.rs", "src/v3/compiler/src/diagnostics_generated.rs", + "src/v3/compiler/src/dimension.rs", "src/v3/compiler/src/emit/collection_ops_method_contract_generated.rs", "src/v3/compiler/src/emit_rust_bin_shim_generated.rs", "src/v3/compiler/src/enforced_lens_application_generated.rs", + "src/v3/compiler/src/infer.rs", "src/v3/compiler/src/infer_helpers_generated.rs", "src/v3/compiler/src/int_literal_ranges_generated.rs", "src/v3/compiler/src/lens_cost_symbolic_generated.rs", @@ -788,6 +807,8 @@ fn main() { "src/v3/compiler/src/lens_unused_parameters_generated.rs", "src/v3/compiler/src/lower_helpers_generated.rs", "src/v3/compiler/src/operators_generated.rs", + "src/v3/compiler/src/pipeline_authority.rs", + "src/v3/compiler/src/post_emit_verifier.rs", "src/v3/compiler/src/pb_method_template_projection_generated.rs", "src/v3/compiler/src/parse_generated.rs", "src/v3/compiler/src/parse_surface_generated.rs", diff --git a/src/v3/compiler/src/cementing_dispatch.rs b/src/v3/compiler/src/cementing_dispatch.rs index c2d66994603..6a9de3c3324 100644 --- a/src/v3/compiler/src/cementing_dispatch.rs +++ b/src/v3/compiler/src/cementing_dispatch.rs @@ -1,8 +1,15 @@ +// AUTO-GENERATED by src/v3/compiler/build.rs (`emit_pb0_cycle4_host_authority_modules`). +// +// Template: `tools/pb0_cycle4_emit_templates/.rs.in` (workspace root). +// Dissolution receipt: PB-0 NON_TEST partition — `REGEN_OUTPUTS` producer; full `.dag` emit T-LP track. +// + //! Band-C cementing dispatch — [`crate::test_runner::TestPredicate::CementingDispatchMatchesProjection`]. //! -//! SG-0 / INVARIANTS P5: hand-authored `src/v3/compiler/src/` module — register the path in -//! `EXPECTED_HAND_AUTHORED_NON_TEST` inside `tests/integration/sg0_census_test.rs` when this -//! file ships or changes materially. +//! SG-0 / INVARIANTS P5: PB-0 generated compiler module. The authoritative body lives in +//! `tools/pb0_cycle4_emit_templates/cementing_dispatch.rs.in`; `build.rs` +//! emits `src/v3/compiler/src/cementing_dispatch.rs` and registers it in +//! `REGEN_OUTPUTS` / `GENERATED_FILES`. //! //! Projects `LensRegistryEntry` rows from the bootstrapped `regen.dag` authority against //! `std.verification` `lens_capability_register_rows` (closed coproduct status axes per @@ -23,9 +30,10 @@ pub mod integration_rs_wiring_scan { //! `tests/integration.rs` wiring scanner (Band-C cementing dispatch). //! - //! SG-0 / INVARIANTS P5: hand-authored Rust under `src/v3/compiler/src/` — keep - //! `EXPECTED_HAND_AUTHORED_NON_TEST` in `tests/integration/sg0_census_test.rs` in lockstep - //! when adding or retiring paths here. + //! SG-0 / INVARIANTS P5: this scanner is hosted by the PB-0 generated + //! `cementing_dispatch.rs` output. Edit the template under + //! `tools/pb0_cycle4_emit_templates/`; `build.rs` refreshes the emitted + //! compiler module. //! //! **ROADMAP / dissolution:** same **v3 lens capability honesty pass** row as //! `src/v3/compiler/tests/integration/common/wiring_scanner_test.rs` in `INVARIANTS.md` § SG-0 — diff --git a/src/v3/compiler/src/diagnostics.rs b/src/v3/compiler/src/diagnostics.rs index 9bf30646ff6..e22d5bd143c 100644 --- a/src/v3/compiler/src/diagnostics.rs +++ b/src/v3/compiler/src/diagnostics.rs @@ -1,3 +1,9 @@ +// AUTO-GENERATED by src/v3/compiler/build.rs (`emit_pb0_cycle4_host_authority_modules`). +// +// Template: `tools/pb0_cycle4_emit_templates/.rs.in` (workspace root). +// Dissolution receipt: PB-0 NON_TEST partition — `REGEN_OUTPUTS` producer; full `.dag` emit T-LP track. +// + // Diagnostics and the enforced mark_unresolved API. // // The load-bearing guarantee: diff --git a/src/v3/compiler/src/dimension.rs b/src/v3/compiler/src/dimension.rs index 28427c626c4..ffdc2ee77e9 100644 --- a/src/v3/compiler/src/dimension.rs +++ b/src/v3/compiler/src/dimension.rs @@ -1,3 +1,9 @@ +// AUTO-GENERATED by src/v3/compiler/build.rs (`emit_pb0_cycle4_host_authority_modules`). +// +// Template: `tools/pb0_cycle4_emit_templates/.rs.in` (workspace root). +// Dissolution receipt: PB-0 NON_TEST partition — `REGEN_OUTPUTS` producer; full `.dag` emit T-LP track. +// + //! DB-3 dimension analysis — Lane 2 Stage 2f. //! //! Type authority: `src/v3/std/dimensions.dag`, spine order: diff --git a/src/v3/compiler/src/infer.rs b/src/v3/compiler/src/infer.rs index 8e62b4bc523..b5205eb552a 100644 --- a/src/v3/compiler/src/infer.rs +++ b/src/v3/compiler/src/infer.rs @@ -1,3 +1,9 @@ +// AUTO-GENERATED by src/v3/compiler/build.rs (`emit_pb0_cycle4_host_authority_modules`). +// +// Template: `tools/pb0_cycle4_emit_templates/.rs.in` (workspace root). +// Dissolution receipt: PB-0 NON_TEST partition — `REGEN_OUTPUTS` producer; full `.dag` emit T-LP track. +// + // Type inference: fills Port.state by propagating types forward through // the DAG. Dispatches on TypeConnective (M1_DESIGN.md §4) instead of the // old name-based function signature lookup. diff --git a/src/v3/compiler/src/pipeline_authority.rs b/src/v3/compiler/src/pipeline_authority.rs index 70f86a7f828..b35341b6c71 100644 --- a/src/v3/compiler/src/pipeline_authority.rs +++ b/src/v3/compiler/src/pipeline_authority.rs @@ -1,3 +1,9 @@ +// AUTO-GENERATED by src/v3/compiler/build.rs (`emit_pb0_cycle4_host_authority_modules`). +// +// Template: `tools/pb0_cycle4_emit_templates/.rs.in` (workspace root). +// Dissolution receipt: PB-0 NON_TEST partition — `REGEN_OUTPUTS` producer; full `.dag` emit T-LP track. +// + use crate::dag::{Dag, DeclarationId, FieldValue, TypeConnective, ValueBody}; #[cfg(test)] diff --git a/src/v3/compiler/src/post_emit_verifier.rs b/src/v3/compiler/src/post_emit_verifier.rs index 463c627193d..266d0f52727 100644 --- a/src/v3/compiler/src/post_emit_verifier.rs +++ b/src/v3/compiler/src/post_emit_verifier.rs @@ -1,3 +1,9 @@ +// AUTO-GENERATED by src/v3/compiler/build.rs (`emit_pb0_cycle4_host_authority_modules`). +// +// Template: `tools/pb0_cycle4_emit_templates/.rs.in` (workspace root). +// Dissolution receipt: PB-0 NON_TEST partition — `REGEN_OUTPUTS` producer; full `.dag` emit T-LP track. +// + //! E-5 / Lane 1 Stage 1c PR 4 — shared test harness that reads each //! target's `CleanEmissionContract.post_emit_verifier` record and //! invokes the declared verifier against an emitted source file. diff --git a/src/v3/compiler/tests/integration/sg0_census_test.rs b/src/v3/compiler/tests/integration/sg0_census_test.rs index 00424983745..3668542c448 100644 --- a/src/v3/compiler/tests/integration/sg0_census_test.rs +++ b/src/v3/compiler/tests/integration/sg0_census_test.rs @@ -270,25 +270,18 @@ const EXPECTED_HAND_AUTHORED_NON_TEST: &[&str] = &[ "src/v3/compiler/src/bin/self_host_fixed_point.rs", "src/v3/compiler/src/bootstrap.rs", "src/v3/compiler/src/bootstrap_regen_fresh.rs", - // R3 gate #87 / T-Tests-As-Data-Completeness: `CementingDispatchMatchesProjection` host - // evaluator for `tests/dag/cementing_dispatch.dag` (P5 consumer receipt; dissolves when - // predicate substrate owns the walk without host FS coupling). - "src/v3/compiler/src/cementing_dispatch.rs", "src/v3/compiler/src/dag.rs", "src/v3/compiler/src/dag/builder.rs", // Closed Cardinality payload + idempotent target shim (API closure). "src/v3/compiler/src/dag/cardinality_payload.rs", "src/v3/compiler/src/dag/effects.rs", "src/v3/compiler/src/dag/ports.rs", - "src/v3/compiler/src/diagnostics.rs", - "src/v3/compiler/src/dimension.rs", "src/v3/compiler/src/emit.rs", "src/v3/compiler/src/emit/python_target.rs", "src/v3/compiler/src/emit/rust_target.rs", "src/v3/compiler/src/emit_rust.rs", // R1C-E + m1_3: shared `PROGRAM_FIXTURES` / `REFLECTED_FIXTURES` tables (single source of truth). "src/v3/compiler/src/emit_rust_roundtrip_fixtures.rs", - "src/v3/compiler/src/infer.rs", // PB-0 cycle-2 (msg_84abadad Track A): `gunbc_ci`, `integration_rs_wiring_scan`, // `r3_gate_87_cementing_regen_runner_suites`, and `lens_t_las_carrier` are nested `pub mod` // surfaces under `cementing_dispatch.rs` / `lens_declaration_apply` (same APIs via `lib.rs` @@ -304,8 +297,6 @@ const EXPECTED_HAND_AUTHORED_NON_TEST: &[&str] = &[ // fix moved it out of `emit.rs`. Dissolves when the equivalent Shape B // `.dag` program owns the OpenAPI artifact projection end-to-end. "src/v3/compiler/src/omni_shape_b_openapi.rs", - "src/v3/compiler/src/pipeline_authority.rs", - "src/v3/compiler/src/post_emit_verifier.rs", // PB-1 Item 5: host mirror of `dsl/std/process.dag` `ProcessExit` for emitted bin shims. "src/v3/compiler/src/process_exit.rs", // R1C-E (T-Emit `.dag` `TestClaim` wrappers): shared `check_*` API the host diff --git a/tools/pb0_cycle4_emit_templates/cementing_dispatch.rs.in b/tools/pb0_cycle4_emit_templates/cementing_dispatch.rs.in new file mode 100644 index 00000000000..affd27617d5 --- /dev/null +++ b/tools/pb0_cycle4_emit_templates/cementing_dispatch.rs.in @@ -0,0 +1,1590 @@ +//! Band-C cementing dispatch — [`crate::test_runner::TestPredicate::CementingDispatchMatchesProjection`]. +//! +//! SG-0 / INVARIANTS P5: PB-0 generated compiler module. The authoritative body lives in +//! `tools/pb0_cycle4_emit_templates/cementing_dispatch.rs.in`; `build.rs` +//! emits `src/v3/compiler/src/cementing_dispatch.rs` and registers it in +//! `REGEN_OUTPUTS` / `GENERATED_FILES`. +//! +//! Projects `LensRegistryEntry` rows from the bootstrapped `regen.dag` authority against +//! `std.verification` `lens_capability_register_rows` (closed coproduct status axes per +//! `docs/design-tests-as-data-completeness.md` §8.3), then validates the Band-C receipt list +//! in `cementing_dispatch.dag` and on-disk harness artifacts. +//! +//! **Interim host coupling:** validating `temporary-rust` receipts scans `tests/integration.rs` +//! once per predicate evaluation (see `std::fs::read_to_string` below). Dissolution: consume +//! reflected wiring facts at the runner edge so Band-C dispatch does not depend on crate layout +//! + host FS for this check. +//! +//! **Interim projection bridge:** `expected_cementing_receipt_triples` is a fail-closed Rust match +//! from `(registry_name, regen lens_file basename)` to the canonical receipt triples the +//! `cementing_band_c_v2_complete_receipts` list must equal. Dissolution: move that expansion into +//! `.dag` data (fixture or `std.verification`) so the predicate reads a single structural receipt +//! roster keyed off the register ∩ `regen.dag` projection, with no parallel Rust roster. + +pub mod integration_rs_wiring_scan { + //! `tests/integration.rs` wiring scanner (Band-C cementing dispatch). + //! + //! SG-0 / INVARIANTS P5: this scanner is hosted by the PB-0 generated + //! `cementing_dispatch.rs` output. Edit the template under + //! `tools/pb0_cycle4_emit_templates/`; `build.rs` refreshes the emitted + //! compiler module. + //! + //! **ROADMAP / dissolution:** same **v3 lens capability honesty pass** row as + //! `src/v3/compiler/tests/integration/common/wiring_scanner_test.rs` in `INVARIANTS.md` § SG-0 — + //! Band-C `#[path]` / `mod` wiring is enforced without `syn` until reflected integration facts exist. + //! **Dissolution:** remove this scanner when `tests/integration.rs` cementing module wiring can be + //! validated structurally (no line-oriented scan). Raw/byte string openers in **Code** return + //! `Err` so `CementingDispatchMatchesProjection` surfaces a claim failure instead of aborting the runner. + //! + //! Lives in the compiler crate so `test_runner` can validate + //! `#[path = "integration/cementing/.rs"]` + `mod ;` pairings without + //! duplicating the scanner across test binaries. + + /// `true` when `bytes[i..]` opens a `b"…"`, `br#"…"#`, `r#"…"#`, or `r"…"` literal in + /// Rust source. The wiring scanner does not model these — return an error if they appear + /// in `Code` so Band-C ratchets surface `ClaimResult::Fail` instead of aborting. + fn code_opens_raw_or_byte_string_literal(bytes: &[u8], i: usize) -> bool { + match bytes.get(i) { + Some(b'b') => { + if bytes.get(i + 1) == Some(&b'"') { + return true; + } + if bytes.get(i + 1) == Some(&b'r') { + let mut j = i + 2; + while bytes.get(j) == Some(&b'#') { + j += 1; + } + return bytes.get(j) == Some(&b'"'); + } + false + } + Some(b'r') => { + let mut j = i + 1; + while bytes.get(j) == Some(&b'#') { + j += 1; + } + bytes.get(j) == Some(&b'"') + } + _ => false, + } + } + + /// Byte index after the last byte of a Rust escape sequence starting at `backslash` + /// (`bytes[backslash] == b'\\'`). Matches suffix forms used in character and byte + /// literals (`\n`, `\t`, `\xNN`, `\u{…}`, …). Unicode escapes use **only** the braced + /// `\u{…}` form in modern Rust; there is no separate `\uXXXX` tail to scan. + fn integration_rs_escape_sequence_end(bytes: &[u8], backslash: usize) -> usize { + debug_assert_eq!(bytes.get(backslash), Some(&b'\\')); + let i = backslash + 1; + if i >= bytes.len() { + return bytes.len(); + } + match bytes[i] { + b'x' => (backslash + 4).min(bytes.len()), + b'u' if bytes.get(i + 1) == Some(&b'{') => { + let mut j = i + 2; + while j < bytes.len() && bytes[j] != b'}' { + j += 1; + } + (j + 1).min(bytes.len()) + } + _ => (backslash + 2).min(bytes.len()), + } + } + + #[derive(Clone, Copy, PartialEq, Eq)] + enum IntegrationRsScan { + Code, + LineComment, + BlockComment(u32), + String, + /// Inside `b'…'` after consuming the `b'` prefix. + ByteCharLiteral, + /// Inside `'…'` after consuming the opening `'`. + CharLiteral, + } + + fn integration_rs_reject_raw_string_in_code(bytes: &[u8], i: usize) -> Result<(), String> { + if code_opens_raw_or_byte_string_literal(bytes, i) { + return Err(format!( + "integration_rs wiring scan: raw/byte string literal at byte offset {i} \ + (e.g. r\"…\", r#\"…\"#, b\"…\", br#\"…\"#). Extend IntegrationRsScan before scanning this file." + )); + } + Ok(()) + } + + /// Byte offsets of `needle` in `integration_rs` that occur in **Code** (outside + /// `//`, `/* */`, `"…"` strings, `b'…'` byte literals, and `'…'` character literals). + /// Lifetimes and labels (`'a`, `'static`) are skipped without entering string/literal + /// states. See [`integration_rs_active_line_contains`]. + fn integration_rs_code_substring_positions( + integration_rs: &str, + needle: &str, + ) -> Result, String> { + let mut hits = Vec::new(); + if needle.is_empty() { + hits.push(0); + return Ok(hits); + } + let bytes = integration_rs.as_bytes(); + let mut i = 0usize; + let mut state = IntegrationRsScan::Code; + + while i < bytes.len() { + match state { + IntegrationRsScan::Code => { + integration_rs_reject_raw_string_in_code(bytes, i)?; + if bytes[i] == b'/' && i + 1 < bytes.len() { + if bytes[i + 1] == b'/' { + state = IntegrationRsScan::LineComment; + i += 2; + continue; + } + if bytes[i + 1] == b'*' { + state = IntegrationRsScan::BlockComment(1); + i += 2; + continue; + } + } + if bytes.get(i) == Some(&b'b') && bytes.get(i + 1) == Some(&b'\'') { + state = IntegrationRsScan::ByteCharLiteral; + i += 2; + continue; + } + if bytes[i] == b'\'' { + if bytes.get(i + 1) == Some(&b'\\') { + state = IntegrationRsScan::CharLiteral; + i += 1; + continue; + } + if let Some(rest) = integration_rs.get(i + 1..) { + let mut chars = rest.chars(); + if let Some(c) = chars.next() { + let after_char = i + 1 + c.len_utf8(); + if bytes.get(after_char) == Some(&b'\'') { + i = after_char + 1; + continue; + } + } + } + i += 1; + while i < bytes.len() + && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') + { + i += 1; + } + continue; + } + if bytes[i] == b'"' { + state = IntegrationRsScan::String; + i += 1; + continue; + } + if integration_rs[i..].starts_with(needle) { + hits.push(i); + } + i += 1; + } + IntegrationRsScan::LineComment => { + if bytes[i] == b'\n' { + state = IntegrationRsScan::Code; + } + i += 1; + } + IntegrationRsScan::BlockComment(depth) => { + if bytes[i] == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'*' { + state = IntegrationRsScan::BlockComment(depth + 1); + i += 2; + continue; + } + if bytes[i] == b'*' && i + 1 < bytes.len() && bytes[i + 1] == b'/' { + let d = depth - 1; + i += 2; + state = if d == 0 { + IntegrationRsScan::Code + } else { + IntegrationRsScan::BlockComment(d) + }; + continue; + } + i += 1; + } + IntegrationRsScan::String => match bytes[i] { + b'\\' => { + i = (i + 2).min(bytes.len()); + } + b'"' => { + i += 1; + state = IntegrationRsScan::Code; + } + _ => { + i += 1; + } + }, + IntegrationRsScan::ByteCharLiteral => { + if bytes.get(i) == Some(&b'\\') { + i = integration_rs_escape_sequence_end(bytes, i); + } else { + i += 1; + } + if bytes.get(i) == Some(&b'\'') { + i += 1; + } + state = IntegrationRsScan::Code; + } + IntegrationRsScan::CharLiteral => { + if bytes.get(i) == Some(&b'\\') { + i = integration_rs_escape_sequence_end(bytes, i); + } else if let Some(rest) = integration_rs.get(i..) { + let mut ci = rest.chars(); + if let Some(c) = ci.next() { + i += c.len_utf8(); + } else { + state = IntegrationRsScan::Code; + continue; + } + } else { + state = IntegrationRsScan::Code; + continue; + } + if bytes.get(i) == Some(&b'\'') { + i += 1; + } + state = IntegrationRsScan::Code; + } + } + } + Ok(hits) + } + + /// Consume one outer `#[ … ]` attribute (handles quoted `"` regions and nested `[` `]`). + fn skip_rust_attribute(s: &str) -> Option<&str> { + if !s.starts_with("#[") { + return None; + } + let bytes = s.as_bytes(); + let mut i = 1usize; + let mut depth = 0usize; + let mut in_string = false; + let mut escape = false; + while i < bytes.len() { + let b = bytes[i]; + if in_string { + if escape { + escape = false; + i += 1; + continue; + } + if b == b'\\' { + escape = true; + i += 1; + continue; + } + if b == b'"' { + in_string = false; + } + i += 1; + continue; + } + match b { + b'"' => { + in_string = true; + i += 1; + } + b'[' => { + depth += 1; + i += 1; + } + b']' => { + depth = depth.saturating_sub(1); + i += 1; + if depth == 0 { + return Some(&s[i..]); + } + } + _ => { + i += 1; + } + } + } + None + } + + /// After a `#[path = "integration/cementing/.rs"]` attribute in Code, the next + /// Rust item must be `mod ;` (optionally separated by whitespace, newlines, or + /// additional `#[…]` attributes). + fn mod_decl_follows_cementing_path_tail(mut tail: &str, stem: &str) -> bool { + let prefix = format!("mod {stem}"); + loop { + tail = tail.trim_start(); + if tail.is_empty() { + return false; + } + if tail.starts_with("#[") { + let Some(rest) = skip_rust_attribute(tail) else { + return false; + }; + tail = rest; + continue; + } + if !tail.starts_with(&prefix) { + return false; + } + let after_prefix = &tail[prefix.len()..]; + if after_prefix + .chars() + .next() + .is_some_and(|c| c.is_ascii_alphanumeric() || c == '_') + { + // e.g. `mod stem` must not match `mod stem_extra`. + return false; + } + let after = after_prefix.trim_start(); + return after.starts_with(';'); + } + } + + /// `true` when `integration_rs` contains `#[path = "integration/cementing/.rs"]` + /// in **Code** and that attribute is immediately followed (per + /// [`mod_decl_follows_cementing_path_tail`]) by `mod ;`. + /// + /// This is stricter than substring checks for `#[path]` and `mod` separately — it + /// rejects a mismatched `#[path]` on one module paired with a stray `mod ;` + /// elsewhere (`TESTING.md` Band-C dispatch). + pub fn integration_rs_cementing_path_attr_binds_mod_stem( + integration_rs: &str, + stem: &str, + ) -> Result { + let path_attr = format!(r#"#[path = "integration/cementing/{stem}.rs"]"#); + for pos in integration_rs_code_substring_positions(integration_rs, &path_attr)? { + let Some(tail) = integration_rs.get(pos + path_attr.len()..) else { + continue; + }; + if mod_decl_follows_cementing_path_tail(tail, stem) { + return Ok(true); + } + } + Ok(false) + } + + /// True when `needle` appears in `integration_rs` **outside** Rust line comments + /// (`//` … including `///` / `//!`), **nested** block comments (`/* … */`), normal + /// `"…"` string literals, `b'…'` byte character literals, and `'…'` character + /// literals (excluding lifetimes / labels such as `'a` or `'static`). + /// + /// Band-C wiring ratchets use this so `#[path = …]` / `mod …;` matches cannot + /// false-green on commented-out or string-embedded copies. Needles are ASCII + /// (`#[path`, `mod foo`); the scan is byte-oriented on UTF-8 boundaries. + /// + /// **Not handled:** raw strings (`r#"…"#`), byte strings (`b"…"`, `br#"…"#`). If those appear in + /// scanned **Code**, this returns `Err` with an explicit message (so `TestPredicate` callers can + /// map to `ClaimResult::Fail`) until `IntegrationRsScan` is widened for those forms. + pub fn integration_rs_active_line_contains( + integration_rs: &str, + needle: &str, + ) -> Result { + Ok(!integration_rs_code_substring_positions(integration_rs, needle)?.is_empty()) + } +} + +pub mod r3_gate_87_cementing_regen_runner_suites { + //! R3 gate #87 — PB-B-1 runner table for `tests/dag/t_r3_gate_87_cementing_regen_*.dag` harnesses. + //! + //! **INVARIANTS P2 / single authority:** `R3_GATE_87_CEMENTING_REGEN_SUITES` is the only merge-visible + //! inventory for which harnesses `t_pb_b_1_dag_runner_test` executes. `cementing_dispatch` and + //! `r3_gate_87_lens_cementing_regen_receipts_test` consume the same table so `.dag` Band-C receipts + //! cannot pass `CementingDispatchMatchesProjection` without a matching runner row. + + use std::collections::BTreeSet; + use std::path::Path; + + /// `LensRegistryEntry.name` values implied by [`R3_GATE_87_CEMENTING_REGEN_SUITES`] harness `file` + /// paths (`t_r3_gate_87_cementing_regen_.dag`). **Single authority** for the gate-#87 + /// inventory ratchet: `r3_gate_87_lens_cementing_regen_receipts_test` compares live `regen.dag` + /// names against this set so a new registry row cannot ship without a matching runner row. + pub fn r3_gate_87_cementing_regen_lens_names_for_runner_table() -> BTreeSet { + R3_GATE_87_CEMENTING_REGEN_SUITES + .iter() + .map(|(_, file, _, _)| lens_name_from_gate_87_harness_path(file)) + .collect() + } + + /// Module stems (`tests/dag/.dag`) enumerated in [`R3_GATE_87_CEMENTING_REGEN_SUITES`]. + /// [`crate::cementing_dispatch::evaluate_cementing_dispatch_projection`] requires every + /// `kind == "dag"` receipt in `cementing_dispatch.dag` to use one of these stems so the dispatch + /// list cannot drift from the T-PB-B-1 runner. + pub fn r3_gate_87_cementing_regen_pb_b1_dag_module_stems() -> BTreeSet { + R3_GATE_87_CEMENTING_REGEN_SUITES + .iter() + .map(|(_, file, _, _)| { + Path::new(file) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or_else(|| { + panic!("R3_GATE_87_CEMENTING_REGEN_SUITES: invalid harness path `{file}`") + }) + .to_string() + }) + .collect() + } + + // R3 gate #87 — every `LensRegistryEntry` in `src/v3/compiler/regen.dag` has a + // `src/v3/compiler/tests/dag/t_r3_gate_87_cementing_regen_.dag` harness evaluated here. + const R3_GATE_87_CEMENTING_HARNESS_PATH_PREFIX: &str = + "src/v3/compiler/tests/dag/t_r3_gate_87_cementing_regen_"; + const R3_GATE_87_CEMENTING_HARNESS_PATH_SUFFIX: &str = ".dag"; + + fn lens_name_from_gate_87_harness_path(file: &str) -> String { + file.strip_prefix(R3_GATE_87_CEMENTING_HARNESS_PATH_PREFIX) + .and_then(|rest| rest.strip_suffix(R3_GATE_87_CEMENTING_HARNESS_PATH_SUFFIX)) + .unwrap_or_else(|| { + panic!( + "R3_GATE_87_CEMENTING_REGEN_SUITES: harness path must be \ + `{prefix}{suffix}`, got `{file}`", + prefix = R3_GATE_87_CEMENTING_HARNESS_PATH_PREFIX, + suffix = R3_GATE_87_CEMENTING_HARNESS_PATH_SUFFIX, + ) + }) + .to_string() + } + + pub const R3_GATE_87_CEMENTING_REGEN_SUITES: &[(&str, &str, &str, &[&str])] = &[ + ( + include_str!("../tests/dag/t_r3_gate_87_cementing_regen_cost.dag"), + "src/v3/compiler/tests/dag/t_r3_gate_87_cementing_regen_cost.dag", + "r3_gate_87_cementing_regen_cost_suite", + &[ + "cementing_regen_cost_merge_sort_lens_output_equals", + "cementing_regen_cost", + ], + ), + ( + include_str!("../tests/dag/t_r3_gate_87_cementing_regen_cost_symbolic.dag"), + "src/v3/compiler/tests/dag/t_r3_gate_87_cementing_regen_cost_symbolic.dag", + "r3_gate_87_cementing_regen_cost_symbolic_suite", + &[ + "cementing_regen_cost_symbolic", + "cementing_regen_cost_symbolic_countdown", + ], + ), + ( + include_str!("../tests/dag/t_r3_gate_87_cementing_regen_cost_target_realization.dag"), + "src/v3/compiler/tests/dag/t_r3_gate_87_cementing_regen_cost_target_realization.dag", + "r3_gate_87_cementing_regen_cost_target_realization_suite", + &["cementing_regen_cost_target_realization"], + ), + ( + include_str!("../tests/dag/t_r3_gate_87_cementing_regen_effect_enumeration.dag"), + "src/v3/compiler/tests/dag/t_r3_gate_87_cementing_regen_effect_enumeration.dag", + "r3_gate_87_cementing_regen_effect_enumeration_suite", + &["cementing_regen_effect_enumeration"], + ), + ( + include_str!("../tests/dag/t_r3_gate_87_cementing_regen_infer_helpers.dag"), + "src/v3/compiler/tests/dag/t_r3_gate_87_cementing_regen_infer_helpers.dag", + "r3_gate_87_cementing_regen_infer_helpers_suite", + &["cementing_regen_infer_helpers"], + ), + ( + include_str!("../tests/dag/t_r3_gate_87_cementing_regen_lower_helpers.dag"), + "src/v3/compiler/tests/dag/t_r3_gate_87_cementing_regen_lower_helpers.dag", + "r3_gate_87_cementing_regen_lower_helpers_suite", + &["cementing_regen_lower_helpers"], + ), + ( + include_str!("../tests/dag/t_r3_gate_87_cementing_regen_provenance.dag"), + "src/v3/compiler/tests/dag/t_r3_gate_87_cementing_regen_provenance.dag", + "r3_gate_87_cementing_regen_provenance_suite", + &["cementing_regen_provenance"], + ), + ( + include_str!("../tests/dag/t_r3_gate_87_cementing_regen_structural_resolution.dag"), + "src/v3/compiler/tests/dag/t_r3_gate_87_cementing_regen_structural_resolution.dag", + "r3_gate_87_cementing_regen_structural_resolution_suite", + &["cementing_regen_structural_resolution"], + ), + ( + include_str!("../tests/dag/t_r3_gate_87_cementing_regen_unused_parameters.dag"), + "src/v3/compiler/tests/dag/t_r3_gate_87_cementing_regen_unused_parameters.dag", + "r3_gate_87_cementing_regen_unused_parameters_suite", + &["cementing_regen_unused_parameters"], + ), + ( + include_str!("../tests/dag/t_r3_gate_87_cementing_regen_variant_payload.dag"), + "src/v3/compiler/tests/dag/t_r3_gate_87_cementing_regen_variant_payload.dag", + "r3_gate_87_cementing_regen_variant_payload_suite", + &["cementing_regen_variant_payload"], + ), + ]; +} + +pub mod gunbc_ci { + //! `gunbc.ci` workflow gate selection — **Layer 1** affected-set dispatch substrate. + //! + //! **Authority** (Phase B): `docs/briefs/r3-wave1-s6-slice7-affected-set-impl-worker.md`; + //! fail-closed behavior aligned with + //! `docs/design-t-wad-slice-7-binary-shim-affected-set-selection-canvas.md` §3 (superset + //! table) applied to **gate-id** touch seeds (see §4 for how end-state runners join + //! `NodeRef` / dimension receipts to obligations — **not implemented in this module**); + //! carrier shape: `dsl/gunbc/ci.dag` (`CIWorkflowDag`, `CIGateEdge`: `from` is a prerequisite of `to`). + //! + //! **Layer 1 vs canvas §7 / ROADMAP gate #103:** program row `ci_uses_affected_set_selection` + //! eventually requires the Slice-5 **BinaryShim** runner to wire PR #2713 affected-set + //! receipts through `CIWorkflowDag` / `TestClaim` metadata (canvas §§1.1–1.3, §4, §7). + //! This crate surface **does not** close that hand-off: there is no typed PR #2713 + //! receipt type here and no runner entry mapping `Set` into these APIs. + //! What lands here is the **pure graph planner** — [`select_affected_gates`] — plus a + //! thin [`CiBinaryShimAffectedSetReceipt`] adapter for **gate-id seeds only** (tests + + //! future runner glue once upstream produces `CIGate.id` touches). + //! + //! This module is intentionally **pure**: every decision is a function of + //! [`CiWorkflowDagInput`] + [`CiWorkflowDiff`] only. Git diffs, path-regex, env, + //! and PR #2713 lens output must be **mapped upstream** into [`CiWorkflowDiff`] (or a + //! superset-equivalent touch set) before calling [`select_affected_gates`]. + //! + //! **Verifier ratchet witness (Phase C scaffolding)** — `docs/design-ci-workflow-substrate-shape-2026-05-12.md` + //! S5 + prequeue §5.2: monotone **inclusion** under enlarging the touched-id + //! set (smaller touch ⊆ larger touch ⇒ selected subset ⊆ selected superset). + //! See [`selection_subset_under_touch_set_growth`]. + //! + //! **Expansion semantics:** [`select_affected_gates`] walks each `CIGateEdge` **symmetrically** + //! (treat `from → to` as an undirected adjacency) to fixpoint, so a downstream obligation + //! pulls in its sibling prerequisites (e.g. touching `lint` selects `l1-ratchet`, which + //! forces `tests` because both parents are prerequisites of `l1-ratchet`). The returned + //! order is a **topological sort** of that vertex set on the directed prerequisite subgraph. + //! + //! **Global carrier validity:** before expanding any touch seed, [`select_affected_gates`] requires + //! the **entire** directed prerequisite graph on [`CiWorkflowDagInput::gates`] to be acyclic. A cycle + //! confined to an unselected weak component must still fail closed (P3), not succeed in “narrow” mode. + + use std::collections::{BTreeSet, HashMap, HashSet, VecDeque}; + + /// Structural mirror of `CIGate.id` in `dsl/gunbc/ci.dag`. + pub type CiGateId = String; + + /// Structural mirror of `CIGate` in `dsl/gunbc/ci.dag`. + /// + /// [`select_affected_gates`] consults only [`CiGateMeta::id`]. [`CiGateMeta::blocking`] is carried + /// for **carrier parity** with the DSL record so runner-side dispatch can map rows without + /// a forked struct; merge-blocking vs advisory semantics stay **outside** this selection substrate + /// until a consumer reads the field. + #[derive(Debug, Clone, PartialEq, Eq)] + pub struct CiGateMeta { + pub id: CiGateId, + pub blocking: bool, + } + + /// Structural mirror of `CIWorkflowDag`: gate roster + prerequisite edges. + /// + /// Each edge `(from, to)` means **`from` must complete before `to`** (matches + /// `CIGateEdge { from, to }` in `ci.dag`). + #[derive(Debug, Clone, Default, PartialEq, Eq)] + pub struct CiWorkflowDagInput { + pub gates: Vec, + /// Prerequisite edges: `from` runs before `to`. + pub edges: Vec<(CiGateId, CiGateId)>, + } + + // Practice 4 (coproduct checkpoint, `docs/modeling-discipline.md` §4): + // 🟢 GREEN — terminal upstream-policy boundary for gate-id touch semantics before + // `select_affected_gates`: `TouchAll` is the conservative in-DAG superset seed; + // `TouchedGates` is the explicit finite set upstream mapped from lens/git facts. + // Ledger: no third authority here — path-regex, env, and PR #2713 lens receipts stay + // outside this module (Slice 7 brief + canvas §§1.1–4 end-state runner; Layer 1 only here). + /// Diff-against-base input projected to **gate ids** (single authority at this boundary). + #[derive(Debug, Clone, PartialEq, Eq)] + pub enum CiWorkflowDiff { + /// Every gate id in [`CiWorkflowDagInput::gates`] is treated as directly touched. + TouchAll, + /// Upstream bundle names gate ids with proven or assumed direct impact. + TouchedGates(BTreeSet), + } + + // Practice 4 (coproduct checkpoint, `docs/modeling-discipline.md` §4): + // 🟢 GREEN — terminal typed failure surface for [`select_affected_gates`]: malformed `CIWorkflowDag` + // carrier vs non-acyclic directed prerequisites (full roster graph and/or selected subset; no silent success). + // Ledger: (1) fact placement — not split across consumers; this `Result` boundary owns the `Err` + // coproduct. (2) variant-is-data — rejected; payloads differ structurally. (3) algebraic — not + // `std/` refs. (4) dimensional — not one decomposed axis space; each variant is an irreducible + // outcome at the `dsl/gunbc/ci.dag` structural mirror until `.dag`-owned dispatch absorbs diagnostics. + /// [`select_affected_gates`] failed: malformed `CIWorkflowDag` carrier or non-acyclic prerequisites. + #[derive(Debug, Clone, PartialEq, Eq)] + pub enum CiAffectedGatesError { + /// The same `CIGate.id` appears more than once in [`CiWorkflowDagInput::gates`] (malformed carrier; must not collapse into a `HashSet` silently). + DuplicateGateRosterId { id: CiGateId }, + /// `CIGateEdge` names a gate id not present in [`CiWorkflowDagInput::gates`] (violates single roster authority in `dsl/gunbc/ci.dag`). + UnknownEdgeEndpoint { from: CiGateId, to: CiGateId }, + /// The directed prerequisite graph is not a DAG: either the **full** roster graph from + /// [`CiWorkflowDagInput::edges`] contains a cycle, or the selected vertex set’s induced subgraph + /// does (broken indegree accounting). + NonAcyclicPrerequisiteGraph, + } + + /// On success, returns gate ids to execute: the **connected component** of the seed under the symmetric + /// closure of prerequisite edges (iterate `from ↔ to` adjacency to fixpoint), then + /// **topologically sorted** on the directed subgraph (prerequisites first). + /// + /// **Fail-closed** (`design-t-wad-slice-7-binary-shim-affected-set-selection-canvas.md` §3): + /// any touched id not present in `dag.gates` forces **all** gates listed in `dag.gates` + /// into the plan (superset within this DAG). + /// + /// **Empty diff** (`TouchedGates` empty): returns `Ok` of an **empty** plan — callers map that + /// to “no merge-blocking re-verify” vs “full superset” per repository policy **outside** + /// this function (this module does not read policy carriers). + /// + /// **Malformed carrier:** duplicate [`CiGateMeta::id`] values in [`CiWorkflowDagInput::gates`] + /// yield [`CiAffectedGatesError::DuplicateGateRosterId`] (roster must be unique before any `HashSet` + /// view so gates are not dropped silently). Any edge whose `from` or `to` is absent from that roster + /// yields [`CiAffectedGatesError::UnknownEdgeEndpoint`]. Any directed cycle in the **full** + /// prerequisite graph on the roster (not only the touch-selected component) yields + /// [`CiAffectedGatesError::NonAcyclicPrerequisiteGraph`] instead of returning `Ok` in narrow mode. + pub fn select_affected_gates( + dag: &CiWorkflowDagInput, + diff: &CiWorkflowDiff, + ) -> Result, CiAffectedGatesError> { + validate_unique_gate_roster(dag)?; + let known: HashSet<&str> = dag.gates.iter().map(|g| g.id.as_str()).collect(); + for (from, to) in &dag.edges { + if !known.contains(from.as_str()) || !known.contains(to.as_str()) { + return Err(CiAffectedGatesError::UnknownEdgeEndpoint { + from: from.clone(), + to: to.clone(), + }); + } + } + + // Fail-closed on the whole carrier: a cycle in an unselected component must not yield `Ok` + // for a disjoint touch seed (codex / P3). + topo_sort_subset(dag, &known)?; + + let force_superset = match diff { + CiWorkflowDiff::TouchAll => false, + CiWorkflowDiff::TouchedGates(ids) => ids.iter().any(|id| !known.contains(id.as_str())), + }; + + let seed: HashSet<&str> = if force_superset { + known.clone() + } else { + match diff { + CiWorkflowDiff::TouchAll => known.clone(), + CiWorkflowDiff::TouchedGates(ids) => ids.iter().map(|s| s.as_str()).collect(), + } + }; + + if seed.is_empty() { + return Ok(Vec::new()); + } + + // Symmetric edge closure: expand along `from ↔ to` until stable, then topo-sort + // the induced set on directed prerequisite edges. + let mut selected: HashSet<&str> = seed.clone(); + loop { + let before = selected.len(); + for (from, to) in &dag.edges { + let from = from.as_str(); + let to = to.as_str(); + if selected.contains(from) { + selected.insert(to); + } + if selected.contains(to) { + selected.insert(from); + } + } + if selected.len() == before { + break; + } + } + + topo_sort_subset(dag, &selected) + } + + fn validate_unique_gate_roster(dag: &CiWorkflowDagInput) -> Result<(), CiAffectedGatesError> { + let mut seen: HashSet<&str> = HashSet::new(); + for g in &dag.gates { + if !seen.insert(g.id.as_str()) { + return Err(CiAffectedGatesError::DuplicateGateRosterId { id: g.id.clone() }); + } + } + Ok(()) + } + + /// Set inclusion witness for ratchet tests. + /// + /// Returns whether `select_affected_gates(dag, narrow)` ⊆ `select_affected_gates(dag, wide)` as sets + /// when **both** calls succeed; if either returns `Err`, returns `false`. + /// Intended use: both arguments are [`CiWorkflowDiff::TouchedGates`] with `narrow_ids ⊆ wide_ids`, or + /// `wide` is [`CiWorkflowDiff::TouchAll`] (superset selection). Other pairings are not a refinement + /// partial order and may return `false` even when both plans are individually valid. + pub fn selection_subset_under_touch_set_growth( + dag: &CiWorkflowDagInput, + narrow: &CiWorkflowDiff, + wide: &CiWorkflowDiff, + ) -> bool { + match ( + select_affected_gates(dag, narrow), + select_affected_gates(dag, wide), + ) { + (Ok(a), Ok(b)) => { + let a: BTreeSet<_> = a.into_iter().collect(); + let b: BTreeSet<_> = b.into_iter().collect(); + a.is_subset(&b) + } + _ => false, + } + } + + /// **Layer 1** gate-id touch seed for [`select_affected_gates_for_binary_shim`]. + /// + /// Canvas `design-t-wad-slice-7-binary-shim-affected-set-selection-canvas.md` §1.1 + /// steps 3–5 and §4 require the **runner** to consume PR #2713 structured output, join + /// it with `CIWorkflowDag` / `TestClaim` metadata, and **then** emit executable work. + /// This struct is **not** that receipt: it only carries `CIGate.id` strings (and a + /// narrowing flag) **after** any `NodeRef` → gate-id mapping has happened elsewhere. + /// + /// When `narrowing_available` is `false`, selection fails closed to the full gate + /// roster ([`CiWorkflowDiff::TouchAll`]; canvas §3: unknown dimension / missing + /// receipt / unbuildable DAG pair). + #[derive(Debug, Clone, PartialEq, Eq, Default)] + pub struct CiBinaryShimAffectedSetReceipt { + /// `false` forces [`CiWorkflowDiff::TouchAll`] regardless of `proven_direct_gate_touches`. + pub narrowing_available: bool, + /// Gate ids proven directly touched **after** upstream mapping (seed before symmetric expansion). + pub proven_direct_gate_touches: BTreeSet, + } + + /// Layer 1 entry: map a **gate-id-only** [`CiBinaryShimAffectedSetReceipt`] into [`select_affected_gates`]. + pub fn select_affected_gates_for_binary_shim( + dag: &CiWorkflowDagInput, + receipt: &CiBinaryShimAffectedSetReceipt, + ) -> Result, CiAffectedGatesError> { + let diff = if receipt.narrowing_available { + CiWorkflowDiff::TouchedGates(receipt.proven_direct_gate_touches.clone()) + } else { + CiWorkflowDiff::TouchAll + }; + select_affected_gates(dag, &diff) + } + + fn topo_sort_subset( + dag: &CiWorkflowDagInput, + subset: &HashSet<&str>, + ) -> Result, CiAffectedGatesError> { + let mut indegree: HashMap<&str, usize> = HashMap::new(); + for id in subset.iter().copied() { + indegree.entry(id).or_insert(0); + } + for (from, to) in &dag.edges { + let from = from.as_str(); + let to = to.as_str(); + if subset.contains(from) && subset.contains(to) { + *indegree.entry(to).or_insert(0) += 1; + } + } + + let mut queue: VecDeque<&str> = indegree + .iter() + .filter(|(_, d)| **d == 0) + .map(|(n, _)| *n) + .collect::>() + .into_iter() + .collect(); + + let mut out: Vec = Vec::new(); + while let Some(n) = queue.pop_front() { + out.push(n.to_string()); + for (from, to) in &dag.edges { + if from.as_str() != n { + continue; + } + let to = to.as_str(); + if !subset.contains(to) { + continue; + } + if let Some(d) = indegree.get_mut(to) { + *d -= 1; + if *d == 0 { + queue.push_back(to); + } + } + } + } + + if out.len() != subset.len() { + return Err(CiAffectedGatesError::NonAcyclicPrerequisiteGraph); + } + Ok(out) + } + + #[cfg(test)] + mod tests { + use super::*; + + fn demo_ci_dag() -> CiWorkflowDagInput { + CiWorkflowDagInput { + gates: vec![ + CiGateMeta { + id: "compile-gates".into(), + blocking: true, + }, + CiGateMeta { + id: "lint".into(), + blocking: true, + }, + CiGateMeta { + id: "tests".into(), + blocking: true, + }, + CiGateMeta { + id: "l1-ratchet".into(), + blocking: true, + }, + ], + edges: vec![ + ("compile-gates".into(), "lint".into()), + ("compile-gates".into(), "tests".into()), + ("lint".into(), "l1-ratchet".into()), + ("tests".into(), "l1-ratchet".into()), + ], + } + } + + #[test] + fn select_affected_gates_empty_diff_returns_empty_plan() { + let dag = demo_ci_dag(); + let diff = CiWorkflowDiff::TouchedGates(BTreeSet::new()); + assert_eq!(select_affected_gates(&dag, &diff), Ok(Vec::new())); + } + + #[test] + fn select_affected_gates_touch_all_runs_full_dag_in_topo_order() { + let dag = demo_ci_dag(); + let got = + select_affected_gates(&dag, &CiWorkflowDiff::TouchAll).expect("demo dag is valid"); + assert_eq!( + got, + vec![ + "compile-gates".to_string(), + "lint".to_string(), + "tests".to_string(), + "l1-ratchet".to_string(), + ] + ); + } + + #[test] + fn select_affected_gates_isolated_gate_no_edges() { + let dag = CiWorkflowDagInput { + gates: vec![CiGateMeta { + id: "solo".into(), + blocking: true, + }], + edges: vec![], + }; + let diff = CiWorkflowDiff::TouchedGates(BTreeSet::from(["solo".into()])); + assert_eq!( + select_affected_gates(&dag, &diff), + Ok(vec!["solo".to_string()]) + ); + } + + #[test] + fn select_affected_gates_unknown_touch_id_forces_superset() { + let dag = demo_ci_dag(); + let diff = CiWorkflowDiff::TouchedGates(BTreeSet::from(["no-such-gate".into()])); + let got = select_affected_gates(&dag, &diff).expect("superset path is acyclic"); + let expect = select_affected_gates(&dag, &CiWorkflowDiff::TouchAll).expect("demo dag"); + assert_eq!(got, expect); + } + + #[test] + fn select_affected_gates_rejects_unknown_edge_endpoint() { + let dag = CiWorkflowDagInput { + gates: vec![CiGateMeta { + id: "solo".into(), + blocking: true, + }], + edges: vec![("solo".into(), "phantom".into())], + }; + let diff = CiWorkflowDiff::TouchedGates(BTreeSet::from(["solo".into()])); + assert_eq!( + select_affected_gates(&dag, &diff), + Err(CiAffectedGatesError::UnknownEdgeEndpoint { + from: "solo".into(), + to: "phantom".into(), + }) + ); + } + + #[test] + fn select_affected_gates_rejects_duplicate_gate_roster_ids() { + let dag = CiWorkflowDagInput { + gates: vec![ + CiGateMeta { + id: "dup".into(), + blocking: true, + }, + CiGateMeta { + id: "dup".into(), + blocking: false, + }, + ], + edges: vec![], + }; + assert_eq!( + select_affected_gates(&dag, &CiWorkflowDiff::TouchAll), + Err(CiAffectedGatesError::DuplicateGateRosterId { id: "dup".into() }) + ); + } + + #[test] + fn select_affected_gates_rejects_prerequisite_cycle() { + let dag = CiWorkflowDagInput { + gates: vec![ + CiGateMeta { + id: "a".into(), + blocking: true, + }, + CiGateMeta { + id: "b".into(), + blocking: true, + }, + ], + edges: vec![("a".into(), "b".into()), ("b".into(), "a".into())], + }; + assert_eq!( + select_affected_gates(&dag, &CiWorkflowDiff::TouchAll), + Err(CiAffectedGatesError::NonAcyclicPrerequisiteGraph) + ); + } + + #[test] + fn select_affected_gates_rejects_global_cycle_even_when_touch_isolated() { + let dag = CiWorkflowDagInput { + gates: vec![ + CiGateMeta { + id: "a".into(), + blocking: true, + }, + CiGateMeta { + id: "b".into(), + blocking: true, + }, + CiGateMeta { + id: "c".into(), + blocking: true, + }, + ], + edges: vec![("a".into(), "b".into()), ("b".into(), "a".into())], + }; + let diff = CiWorkflowDiff::TouchedGates(BTreeSet::from(["c".into()])); + assert_eq!( + select_affected_gates(&dag, &diff), + Err(CiAffectedGatesError::NonAcyclicPrerequisiteGraph) + ); + } + + #[test] + fn select_affected_gates_empty_touch_rejects_when_global_graph_has_cycle() { + let dag = CiWorkflowDagInput { + gates: vec![ + CiGateMeta { + id: "a".into(), + blocking: true, + }, + CiGateMeta { + id: "b".into(), + blocking: true, + }, + ], + edges: vec![("a".into(), "b".into()), ("b".into(), "a".into())], + }; + let diff = CiWorkflowDiff::TouchedGates(BTreeSet::new()); + assert_eq!( + select_affected_gates(&dag, &diff), + Err(CiAffectedGatesError::NonAcyclicPrerequisiteGraph) + ); + } + + #[test] + fn select_affected_gates_lint_only_pulls_l1_and_shared_prereqs() { + let dag = demo_ci_dag(); + let diff = CiWorkflowDiff::TouchedGates(BTreeSet::from(["lint".into()])); + let got = select_affected_gates(&dag, &diff).expect("demo dag"); + let set: BTreeSet<_> = got.iter().cloned().collect(); + assert_eq!( + set, + BTreeSet::from([ + "compile-gates".into(), + "lint".into(), + "tests".into(), + "l1-ratchet".into(), + ]) + ); + assert_eq!(got.first().map(String::as_str), Some("compile-gates")); + } + + #[test] + fn selection_subset_under_touch_set_growth_holds_on_demo_dag() { + let dag = demo_ci_dag(); + let narrow = CiWorkflowDiff::TouchedGates(BTreeSet::from(["lint".into()])); + let wide = + CiWorkflowDiff::TouchedGates(BTreeSet::from(["lint".into(), "tests".into()])); + assert!(selection_subset_under_touch_set_growth( + &dag, &narrow, &wide + )); + } + + #[test] + fn selection_subset_empty_diff_is_subset_of_nonempty() { + let dag = demo_ci_dag(); + let empty = CiWorkflowDiff::TouchedGates(BTreeSet::new()); + let wide = CiWorkflowDiff::TouchedGates(BTreeSet::from(["tests".into()])); + assert!(selection_subset_under_touch_set_growth(&dag, &empty, &wide)); + } + + #[test] + fn binary_shim_receipt_unknown_narrowing_matches_touch_all() { + let dag = demo_ci_dag(); + let receipt = CiBinaryShimAffectedSetReceipt { + narrowing_available: false, + proven_direct_gate_touches: BTreeSet::from(["lint".into()]), + }; + let got = select_affected_gates_for_binary_shim(&dag, &receipt).expect("valid dag"); + let expect = select_affected_gates(&dag, &CiWorkflowDiff::TouchAll).expect("valid dag"); + assert_eq!(got, expect); + } + + #[test] + fn binary_shim_receipt_narrow_lint_matches_touched_gates_lint() { + let dag = demo_ci_dag(); + let receipt = CiBinaryShimAffectedSetReceipt { + narrowing_available: true, + proven_direct_gate_touches: BTreeSet::from(["lint".into()]), + }; + let got = select_affected_gates_for_binary_shim(&dag, &receipt).expect("valid dag"); + let expect = select_affected_gates( + &dag, + &CiWorkflowDiff::TouchedGates(BTreeSet::from(["lint".into()])), + ) + .expect("valid dag"); + assert_eq!(got, expect); + } + + #[test] + fn binary_shim_receipt_empty_narrow_seed_yields_empty_plan() { + let dag = demo_ci_dag(); + let receipt = CiBinaryShimAffectedSetReceipt { + narrowing_available: true, + proven_direct_gate_touches: BTreeSet::new(), + }; + assert_eq!( + select_affected_gates_for_binary_shim(&dag, &receipt), + Ok(Vec::new()) + ); + } + } +} + +use self::integration_rs_wiring_scan::integration_rs_cementing_path_attr_binds_mod_stem; +use self::r3_gate_87_cementing_regen_runner_suites::r3_gate_87_cementing_regen_pb_b1_dag_module_stems; +use std::collections::{BTreeSet, HashSet}; +use std::path::Path as FsPath; + +use crate::dag::{Dag, FieldValue, LiteralBits, TypeConnective, ValueBody}; + +fn record_field<'a>(fields: &'a [(String, FieldValue)], label: &str) -> Option<&'a FieldValue> { + fields + .iter() + .find(|(candidate, _)| candidate == label) + .map(|(_, value)| value) +} + +fn string_field(fields: &[(String, FieldValue)], label: &str) -> Result { + match record_field(fields, label) { + Some(FieldValue::Literal(LiteralBits::String(value))) => Ok(value.clone()), + Some(other) => Err(format!("`{label}` must be a string literal, got {other:?}")), + None => Err(format!("record is missing string field `{label}`")), + } +} + +fn disj_variants( + dag: &Dag, + type_name: &str, +) -> Result, String> { + let sum_decl = dag.declaration_by_name(type_name).ok_or_else(|| { + format!( + "merged DAG missing nullary sum `{type_name}` — expected `std.verification` for lens \ + capability axes, or `tests/dag/cementing_dispatch.dag` for `CementingBandCReceiptKind`" + ) + })?; + let TypeConnective::Disj { variants } = &sum_decl.connective else { + return Err(format!("`{type_name}` must be a Disj coproduct")); + }; + Ok(variants.iter().map(|v| (v.label.clone(), v.ty)).collect()) +} + +fn decode_nullary_sum_variant( + dag: &Dag, + sum_type_name: &str, + value: &FieldValue, + field_label: &str, + record_role: &str, +) -> Result { + let variants = disj_variants(dag, sum_type_name)?; + let allowed: HashSet = variants.iter().map(|(_, id)| *id).collect(); + let FieldValue::Variant { + constructor, + payload, + } = value + else { + return Err(format!( + "{record_role} `{field_label}` must be a closed sum variant of `{sum_type_name}`; got {value:?}" + )); + }; + if !payload.is_empty() { + return Err(format!( + "{record_role} `{field_label}`: unexpected variant payload (expected nullary `{sum_type_name}` arm)" + )); + } + if !allowed.contains(constructor) { + return Err(format!( + "{record_role} `{field_label}`: constructor id {:?} is not a declared variant of `{sum_type_name}`", + constructor + )); + } + variants + .iter() + .find(|(_, id)| id == constructor) + .map(|(label, _)| label.clone()) + .ok_or_else(|| { + format!( + "{record_role} `{field_label}`: internal mismatch resolving `{sum_type_name}` variant" + ) + }) +} + +/// Maps `CementingBandCReceiptKind` variant labels to the stable wire tags used in +/// `expected_cementing_receipt_triples` and on-disk dispatch branches. +fn cementing_receipt_kind_wire_tag(sum_variant_label: &str) -> Result<&'static str, String> { + match sum_variant_label { + "DagHarness" => Ok("dag"), + "TemporaryRustModule" => Ok("temporary-rust"), + other => Err(format!( + "cementing receipt `kind`: unknown `CementingBandCReceiptKind` variant `{other}`" + )), + } +} + +fn decode_cementing_band_c_receipt_kind( + dag: &Dag, + value: &FieldValue, + field_label: &str, +) -> Result<&'static str, String> { + let label = decode_nullary_sum_variant( + dag, + "CementingBandCReceiptKind", + value, + field_label, + "cementing receipt row", + )?; + cementing_receipt_kind_wire_tag(&label) +} + +fn record_fields(value: &FieldValue) -> Option<&[(String, FieldValue)]> { + match value { + FieldValue::Record(fields) => Some(fields.as_slice()), + _ => None, + } +} + +fn resolve_declaration_ref_id( + value: &FieldValue, + field_label: &str, +) -> Result { + match value { + FieldValue::Reference(id) => Ok(*id), + FieldValue::Record(fields) if fields.is_empty() => Err(format!( + "`{field_label}`: DeclarationRef is the empty record literal {{}} — use an identifier \ + so lowering emits FieldValue::Reference(DeclarationId), not an empty record", + )), + other => Err(format!( + "`{field_label}`: expected FieldValue::Reference(DeclarationId) \ + for a DeclarationRef edge, got {other:?}" + )), + } +} + +fn list_items_of_declaration( + dag: &Dag, + id: crate::dag::DeclarationId, + role: &str, +) -> Result, String> { + let decl = dag.declaration(id); + match &decl.value_body { + Some(ValueBody::List(items)) => Ok(items.clone()), + other => Err(format!( + "`{role}` must resolve to a `List<…>` data declaration; got {:?} for `{}`", + other, + decl.name.as_deref().unwrap_or("") + )), + } +} + +fn read_lens_registry_name_lens_file_pairs(dag: &Dag) -> Result, String> { + let entry_type_id = dag + .declaration_by_name("LensRegistryEntry") + .map(|decl| decl.id) + .ok_or_else(|| "bootstrap must declare `LensRegistryEntry` (regen.dag)".to_string())?; + + let mut pairs = Vec::new(); + for decl in dag.declarations() { + if decl.meta_tag != Some(entry_type_id) { + continue; + } + let Some(ValueBody::Structural { fields }) = &decl.value_body else { + return Err(format!( + "lens registry entry `{}` must carry a structural value body", + decl.name.as_deref().unwrap_or("") + )); + }; + let binding = decl + .name + .clone() + .unwrap_or_else(|| "".to_string()); + pairs.push(( + string_field(fields, "name") + .map_err(|e| format!("lens registry entry `{binding}`: {e}"))?, + string_field(fields, "lens_file") + .map_err(|e| format!("lens registry entry `{binding}`: {e}"))?, + )); + } + Ok(pairs) +} + +/// Closed roster of Band-C `(registry_name, module_stem, kind)` receipts implied by the +/// current `regen.dag` × v2-complete register projection. When a new v2-complete lens +/// lands in `regen.dag`, extend this expansion in the same PR as `cementing_dispatch.dag` +/// receipt rows — otherwise the dispatch predicate fail-closes with an explicit error. +fn expected_cementing_receipt_triples( + registry_pairs: &[(String, String)], + basenames: &BTreeSet, +) -> Result, String> { + let mut out = BTreeSet::new(); + for (name, lens_file) in registry_pairs { + let basename = FsPath::new(lens_file) + .file_name() + .and_then(|s| s.to_str()) + .ok_or_else(|| { + format!("registry entry `{name}` has lens_file without basename: {lens_file}") + })? + .to_string(); + if !basenames.contains(&basename) { + continue; + } + match (name.as_str(), basename.as_str()) { + ("cost", "complexity.dag") => { + out.insert(( + name.clone(), + "t_r3_gate_87_cementing_regen_cost".to_string(), + "dag".to_string(), + )); + out.insert(( + name.clone(), + "complexity_lens_behavioral_completion".to_string(), + "temporary-rust".to_string(), + )); + } + ("cost_symbolic", "cost.dag") => { + out.insert(( + name.clone(), + "t_r3_gate_87_cementing_regen_cost_symbolic".to_string(), + "dag".to_string(), + )); + out.insert(( + name.clone(), + "cost_lens_symbolic_consumer_test".to_string(), + "temporary-rust".to_string(), + )); + } + _ => { + return Err(format!( + "Band-C v2 cementing projection includes `LensRegistryEntry` \ + (`name={name}`, `lens_file` basename `{basename}`) but \ + `expected_cementing_receipt_triples` has no receipt expansion — \ + land matching `cementing_band_c_v2_complete_receipts` rows in \ + `cementing_dispatch.dag` and extend the expansion table in \ + `cementing_dispatch.rs` in the same PR." + )); + } + } + } + Ok(out) +} + +fn v2_cementing_basenames_from_capability_rows( + dag: &Dag, + capability_rows: &[FieldValue], +) -> Result, String> { + let mut basenames = BTreeSet::new(); + for row in capability_rows { + let Some(fields) = record_fields(row) else { + return Err(format!( + "capability_register list: expected record row, got {row:?}" + )); + }; + let lens_basename = string_field(fields, "lens_basename")?; + let structural = record_field(fields, "structural").ok_or_else(|| { + "capability_register row: missing `structural` field (expected \ + `LensCapabilityStructuralStatus` variant)" + .to_string() + })?; + let behavioral = record_field(fields, "behavioral").ok_or_else(|| { + "capability_register row: missing `behavioral` field (expected \ + `LensCapabilityBehavioralStatus` variant)" + .to_string() + })?; + let v2 = record_field(fields, "v2_counterpart").ok_or_else(|| { + "capability_register row: missing `v2_counterpart` field (expected \ + `LensCapabilityV2Counterpart` variant)" + .to_string() + })?; + decode_nullary_sum_variant( + dag, + "LensCapabilityStructuralStatus", + structural, + "structural", + "capability_register row", + )?; + let behavioral_label = decode_nullary_sum_variant( + dag, + "LensCapabilityBehavioralStatus", + behavioral, + "behavioral", + "capability_register row", + )?; + let v2_label = decode_nullary_sum_variant( + dag, + "LensCapabilityV2Counterpart", + v2, + "v2_counterpart", + "capability_register row", + )?; + if behavioral_label == "LensCapabilityBehavioralComplete" + && v2_label == "LensCapabilityV2RealV2" + { + basenames.insert(lens_basename); + } + } + Ok(basenames) +} + +/// Lens basenames that participate in Band-C v2 cementing: `LensCapabilityBehavioralComplete` +/// plus `LensCapabilityV2RealV2` in the canonical `std.verification` `lens_capability_register_rows` +/// list (same projection `CementingDispatchMatchesProjection` uses before intersecting `regen.dag`). +/// +/// Exposed for integration tests that mechanically ratchet the markdown capability table against +/// this structural authority (`lens_register_correspondence_test`). +pub fn lens_capability_register_v2_cementing_basenames( + dag: &Dag, +) -> Result, String> { + let reg_decl = dag + .declaration_by_name("lens_capability_register_rows") + .ok_or_else(|| { + "bootstrap must declare `lens_capability_register_rows` (std.verification)".to_string() + })?; + let capability_rows = + list_items_of_declaration(dag, reg_decl.id, "lens_capability_register_rows")?; + v2_cementing_basenames_from_capability_rows(dag, &capability_rows) +} + +fn cementing_dispatch_two_refs( + payload: &[FieldValue], +) -> Result<(&FieldValue, &FieldValue), String> { + match payload { + [a, b] => Ok((a, b)), + [FieldValue::Record(fields)] => { + let a = record_field(fields, "capability_register").ok_or_else(|| { + "CementingDispatchMatchesProjection record missing `capability_register`" + .to_string() + })?; + let b = record_field(fields, "cementing_receipts").ok_or_else(|| { + "CementingDispatchMatchesProjection record missing `cementing_receipts`".to_string() + })?; + Ok((a, b)) + } + _ => Err(format!( + "CementingDispatchMatchesProjection payload should be two DeclarationRef edges \ + or a record {{ capability_register, cementing_receipts }}; got {} slot(s)", + payload.len() + )), + } +} + +/// Evaluate `CementingDispatchMatchesProjection` for a claim declared in `declaration_file`. +/// +/// Returns `Ok(())` on pass; `Err(reason)` maps to [`crate::test_runner::ClaimResult::Fail`]. +pub(crate) fn evaluate_cementing_dispatch_projection( + dag: &Dag, + declaration_file: &str, + payload: &[FieldValue], +) -> Result<(), String> { + const CEMENTING_DISPATCH_DECL_PATH: &str = "cementing_dispatch.dag"; + if !declaration_file.ends_with(CEMENTING_DISPATCH_DECL_PATH) { + return Err(format!( + "CementingDispatchMatchesProjection is only accepted for claims declared in `**/{CEMENTING_DISPATCH_DECL_PATH}`; got `{declaration_file}`" + )); + } + + let (reg_ref, recv_ref) = cementing_dispatch_two_refs(payload)?; + let reg_id = resolve_declaration_ref_id(reg_ref, "capability_register")?; + let recv_id = resolve_declaration_ref_id(recv_ref, "cementing_receipts")?; + + let capability_rows = list_items_of_declaration(dag, reg_id, "capability_register")?; + let receipt_rows = list_items_of_declaration(dag, recv_id, "cementing_receipts")?; + + let basenames = v2_cementing_basenames_from_capability_rows(dag, &capability_rows)?; + + let registry_pairs = read_lens_registry_name_lens_file_pairs(dag)?; + + let mut matched_basenames = BTreeSet::new(); + for (name, lens_file) in ®istry_pairs { + let Some(basename) = FsPath::new(lens_file).file_name().and_then(|s| s.to_str()) else { + return Err(format!( + "registry entry `{name}` has lens_file without basename: {lens_file}" + )); + }; + if basenames.contains(basename) { + matched_basenames.insert(basename.to_string()); + } + } + + let missing: Vec<_> = basenames.difference(&matched_basenames).cloned().collect(); + if !missing.is_empty() { + return Err(format!( + "`lens_capability_register` escalates v2 cementing for lens basenames {missing:?}, \ + but no `LensRegistryEntry` in bootstrap names those files — fix the register data or add registry entries." + )); + } + + let mut receipt_triples = BTreeSet::new(); + + for row in &receipt_rows { + let Some(fields) = record_fields(row) else { + return Err(format!( + "cementing_receipts list: expected record row, got {row:?}" + )); + }; + let registry_name = string_field(fields, "registry_name")?; + let module_stem = string_field(fields, "module_stem")?; + let kind_field = record_field(fields, "kind").ok_or_else(|| { + "cementing receipt row: missing `kind` field (expected `CementingBandCReceiptKind` \ + variant)" + .to_string() + })?; + let kind_str = decode_cementing_band_c_receipt_kind(dag, kind_field, "kind")?; + let triple = ( + registry_name.clone(), + module_stem.clone(), + kind_str.to_string(), + ); + if !receipt_triples.insert(triple.clone()) { + return Err(format!( + "cementing_receipts contains duplicate receipt identity {triple:?}; each \ + `(registry_name, module_stem, kind)` must be unique." + )); + } + } + + let expected_triples = expected_cementing_receipt_triples(®istry_pairs, &basenames)?; + if receipt_triples != expected_triples { + return Err(format!( + "cementing_receipts `(registry_name, module_stem, kind)` triples must exactly match \ + the Band-C expansion of the register ∩ regen projection — expected {expected_triples:?}, \ + got {receipt_triples:?}" + )); + } + + let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let pb_b1_gate87_dag_stems = r3_gate_87_cementing_regen_pb_b1_dag_module_stems(); + let integration_rs = match std::fs::read_to_string(manifest_dir.join("tests/integration.rs")) { + Ok(text) => text, + Err(err) => { + return Err(format!( + "read tests/integration.rs for cementing wiring ratchet: {err}" + )); + } + }; + + for row in &receipt_rows { + let Some(fields) = record_fields(row) else { + return Err(format!( + "cementing_receipts list: expected record row, got {row:?}" + )); + }; + let registry_name = string_field(fields, "registry_name")?; + let module_stem = string_field(fields, "module_stem")?; + let kind_field = record_field(fields, "kind").ok_or_else(|| { + "cementing receipt row: missing `kind` field (expected `CementingBandCReceiptKind` \ + variant)" + .to_string() + })?; + let kind_str = decode_cementing_band_c_receipt_kind(dag, kind_field, "kind")?; + match kind_str { + "dag" => { + if !pb_b1_gate87_dag_stems.contains(&module_stem) { + return Err(format!( + "registry lens `{registry_name}` lists v2-complete cementing receipt `{module_stem}` \ + as a `.dag` harness, but that stem is not wired in \ + `r3_gate_87_cementing_regen_runner_suites::R3_GATE_87_CEMENTING_REGEN_SUITES` \ + (T-PB-B-1 runner) — extend the shared table or fix the receipt list." + )); + } + let path = manifest_dir + .join("tests") + .join("dag") + .join(format!("{module_stem}.dag")); + if !path.is_file() { + return Err(format!( + "registry lens `{registry_name}` lists v2-complete cementing receipt `{module_stem}` \ + as a `.dag` harness; expected file {}", + path.display() + )); + } + } + "temporary-rust" => { + let path = manifest_dir + .join("tests") + .join("integration") + .join("cementing") + .join(format!("{module_stem}.rs")); + if !path.is_file() { + return Err(format!( + "registry lens `{registry_name}` lists temporary Rust cementing receipt `{module_stem}`; \ + expected file {}", + path.display() + )); + } + if !integration_rs_cementing_path_attr_binds_mod_stem( + &integration_rs, + &module_stem, + )? { + let expected = format!(r#"#[path = "integration/cementing/{module_stem}.rs"]"#); + return Err(format!( + "registry lens `{registry_name}` lists temporary Rust cementing stem `{module_stem}` but \ + tests/integration.rs does not bind `{expected}` to `mod {module_stem};` in the same item \ + (Band-C dispatch)." + )); + } + } + _ => { + return Err( + "cementing receipt `kind`: internal error — wire tag not `dag` or `temporary-rust`" + .to_string(), + ); + } + } + } + + Ok(()) +} diff --git a/tools/pb0_cycle4_emit_templates/diagnostics.rs.in b/tools/pb0_cycle4_emit_templates/diagnostics.rs.in new file mode 100644 index 00000000000..9bf30646ff6 --- /dev/null +++ b/tools/pb0_cycle4_emit_templates/diagnostics.rs.in @@ -0,0 +1,1631 @@ +// Diagnostics and the enforced mark_unresolved API. +// +// The load-bearing guarantee: +// Port.value_type == None iff DiagnosticTable contains PortId +// +// Enforcement is structural: Dag::clear_port_type is pub(crate) and +// is ONLY called from Dag::mark_unresolved (which also writes the +// diagnostic entry atomically). No other call path can null a port. +// +// G2 guardrail: SourceSpan always carries a non-empty `file` field. +// Cross-artifact diagnostics (M2+ multi-file, multi-language) rely +// on spans being addressable across files. Do NOT replace this with +// a bare (line, col) pair — that forecloses the thesis goal. +// +// ──────────────────────────────────────────────────────────────── +// DEFERRED DISSOLUTION: Diagnostic enum is a scaffold. +// +// Current variants (M0.5): TokenizerError, ParseError, TypeMismatch, +// ArityMismatch, ResolveError. The v3 target shape per +// docs/v3-modeling-analysis.md §CompilerDiagnostic is a 5-field +// record: +// +// Diagnostic { +// span: SourceSpan, +// category: DiagnosticCategory, // Type | Cardinality | ... +// subject: DiagnosticSubject, // typed ref to thing flagged +// detail: DiagnosticDetail, // category-specific payload +// correction: Correction, // live fixed code or explicit deferral +// producing_node: Option, +// } +// +// The `correction` field is the thesis claim "show the correct code" +// (THESIS.md §"Error handling"). Every diagnostic that can structurally +// compute a fix must carry one. Design: SELF_HOSTING.md §14.6. +// Roundtrip test pattern: break → diagnose → apply correction → +// recompile → zero diagnostics. Lands at L1.5. +// +// TRIGGER for dissolution: when typed DeclarationId / FieldRef references +// replace the name + span carriers below. M1(2.5) delivered the +// DeclarationId substrate; the subject/detail refactor is next. +// +// Extension from 3→5 variants at M0.5 is deferred dissolution with +// justification: the fail-closed invariant (C-8) requires that every +// detectable failure produces a diagnostic, and M0's infer + lower +// stages reach failure categories (arity, resolve) that TypeMismatch +// does not cover. Until Subject/Detail infrastructure exists, +// extension is the least-bad interim form. +// +// DO NOT add a severity field. C-8: all diagnostics are errors. +// DO NOT add a warning variant. DO NOT add a "maybe wrong" state. +// If a condition is detected and it's wrong, emit the diagnostic +// and fail. If it's not wrong, emit nothing. There is no middle. +// ──────────────────────────────────────────────────────────────── + +use std::collections::HashMap; + +use crate::dag::{ + AtomPayload, CardinalityBound, Dag, DeclarationId, Field, FieldValue, PortId, TypeConnective, + ValueBody, +}; +use crate::types::TypeShape; + +// `SourceSpan` / `Correction` are generated mirrors of std-owned +// substrate/diagnostic carriers. The compiler-local `Diagnostic` +// taxonomy below remains a narrow host shim until the staged compiler +// adopts the shared std diagnostic record directly. +include!("diagnostics_generated.rs"); + +impl Correction { + pub fn live( + description: impl Into, + span: SourceSpan, + new_source: impl Into, + ) -> Self { + Self::LiveCorrection { + witness: CorrectionWitness { + description: description.into(), + span, + new_source: new_source.into(), + }, + } + } + + pub fn deferred( + reason: impl Into, + owner: impl Into, + exit_condition: impl Into, + ) -> Self { + Self::DeferredCorrection { + reason: reason.into(), + retirement_plan: RetirementPlan { + owner: owner.into(), + exit_condition: exit_condition.into(), + }, + } + } + + pub fn deferred_for_diagnostic_class(class: &'static str) -> Self { + Self::deferred( + format!("{class} does not yet compute a source-level live correction witness"), + "R3 Gap 9 row #106 diagnostic roundtrip coverage", + format!( + "replace this deferral with a generated/data-backed LiveCorrection for {class}" + ), + ) + } + + pub fn live_witness(&self) -> Result<&CorrectionWitness, CorrectionApplyError> { + match self { + Correction::LiveCorrection { witness } => Ok(witness), + Correction::DeferredCorrection { reason, .. } => Err(CorrectionApplyError::Deferred { + reason: reason.clone(), + }), + } + } + + pub fn description(&self) -> &str { + match self { + Correction::LiveCorrection { witness } => &witness.description, + Correction::DeferredCorrection { reason, .. } => reason, + } + } + + pub fn span(&self) -> Option<&SourceSpan> { + match self { + Correction::LiveCorrection { witness } => Some(&witness.span), + Correction::DeferredCorrection { .. } => None, + } + } + + pub fn new_source(&self) -> Option<&str> { + match self { + Correction::LiveCorrection { witness } => Some(&witness.new_source), + Correction::DeferredCorrection { .. } => None, + } + } +} + +/// Stable `ResolveError.name` for a negative `PositiveIntervalWidth.UnitCount.units` payload when +/// lowering bypasses the normal `Nat` literal gate (e.g. tests calling `enforce_non_negative_unit_count_payload` directly). +/// +/// Wording is centralized for tests that assert on [`Diagnostic::ResolveError`] directly (see repo +/// `TESTING.md`, behavior-driven). Primary authoring rejects `-1` via [`Diagnostic::MagnitudeOutOfRange`] +/// against `Nat`'s decimal range facts. +#[doc(hidden)] +pub fn positive_interval_width_unit_count_requires_nonnegative_units_literal_message( + units: &str, +) -> String { + format!("PositiveIntervalWidth.UnitCount requires nonnegative `units` literal; got {units}") +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CorrectionApplyError { + InvalidSpan { + start: u32, + end: u32, + source_len: usize, + }, + NonCharBoundary { + offset: u32, + }, + Deferred { + reason: String, + }, +} + +#[derive(Debug, Clone)] +pub enum CorrectionValidationError { + Apply(CorrectionApplyError), + Tokenize(Diagnostic), + Parse(Diagnostic), +} + +pub fn apply_correction( + source: &str, + correction: &Correction, +) -> Result { + let witness = correction.live_witness()?; + let start = witness.span.byte_start as usize; + let end = witness.span.byte_end as usize; + if start > end || end > source.len() { + return Err(CorrectionApplyError::InvalidSpan { + start: witness.span.byte_start, + end: witness.span.byte_end, + source_len: source.len(), + }); + } + if !source.is_char_boundary(start) { + return Err(CorrectionApplyError::NonCharBoundary { + offset: witness.span.byte_start, + }); + } + if !source.is_char_boundary(end) { + return Err(CorrectionApplyError::NonCharBoundary { + offset: witness.span.byte_end, + }); + } + + let mut updated = String::with_capacity(source.len() + witness.new_source.len()); + updated.push_str(&source[..start]); + updated.push_str(&witness.new_source); + updated.push_str(&source[end..]); + Ok(updated) +} + +pub fn apply_correction_and_reparse( + source: &str, + file: &str, + correction: &Correction, +) -> Result { + let updated = apply_correction(source, correction).map_err(CorrectionValidationError::Apply)?; + let tokens = + crate::tokenize::tokenize(&updated, file).map_err(CorrectionValidationError::Tokenize)?; + crate::parse::parse(&tokens, file).map_err(CorrectionValidationError::Parse)?; + Ok(updated) +} + +#[derive(Debug, Clone)] +pub enum Diagnostic { + TokenizerError { + message: String, + span: SourceSpan, + correction: Correction, + }, + ParseError { + message: String, + span: SourceSpan, + correction: Correction, + }, + TypeMismatch { + expected: TypeShape, + actual: TypeShape, + span: SourceSpan, + correction: Correction, + }, + ArityMismatch { + function: String, + expected: usize, + actual: usize, + span: SourceSpan, + correction: Correction, + }, + ResolveError { + name: String, + span: SourceSpan, + correction: Correction, + }, + UnitMismatch { + operator: String, + parameter: String, + expected: TypeShape, + actual: TypeShape, + span: SourceSpan, + correction: Correction, + }, + BranchConditionNotBool { + port: PortId, + actual_type: Option, + span: SourceSpan, + correction: Correction, + }, + MagnitudeOutOfRange { + literal: String, + target: String, + range_min_inclusive: String, + range_max_inclusive: String, + expected: TypeShape, + span: SourceSpan, + correction: Correction, + }, + MalformedIntegerRangeFact { + message: String, + span: SourceSpan, + correction: Correction, + }, + NominalOpacityViolation { + declaration: DeclarationId, + accessor: Option, + span: SourceSpan, + correction: Correction, + }, +} + +/// Layer-1 compiler diagnostic kind labels in **declaration order** of [`Diagnostic`]. +/// +/// This is the **only** source of label strings for [`Diagnostic::layer1_kind_label`], which +/// indexes this slice using an exhaustive per-variant index match (no wildcard). Crate tests assert +/// the declaration-order fixture matches those indices; integration tests ratchet +/// `CompilerDiagnosticKind` / `std.verification.DiagnosticKind` in the bootstrap DAG against this +/// slice. When adding a [`Diagnostic`] variant, extend this list, add an index arm next to +/// `layer1_kind_label`, and update the `.dag` closed sums — lens-instance kinds stay on the +/// Layer-2 path (Q6.5), not here. +pub const LAYER1_DIAGNOSTIC_KIND_LABELS: &[&str] = &[ + "TokenizerError", + "ParseError", + "TypeMismatch", + "ArityMismatch", + "ResolveError", + "UnitMismatch", + "BranchConditionNotBool", + "MagnitudeOutOfRange", + "MalformedIntegerRangeFact", + "NominalOpacityViolation", +]; + +impl Diagnostic { + /// Zero-based declaration index for Layer-1 kind strings (same order as [`Diagnostic`] and + /// [`LAYER1_DIAGNOSTIC_KIND_LABELS`]). + /// + /// This match is **exhaustive with no wildcard**: adding a [`Diagnostic`] variant is a compile + /// error until a new arm is authored here **and** [`LAYER1_DIAGNOSTIC_KIND_LABELS`] is extended + /// by one entry. [`Diagnostic::layer1_kind_label`] reads only from that slice, so the label + /// strings have a single authority. + fn layer1_declaration_index(&self) -> usize { + match self { + Diagnostic::TokenizerError { .. } => 0, + Diagnostic::ParseError { .. } => 1, + Diagnostic::TypeMismatch { .. } => 2, + Diagnostic::ArityMismatch { .. } => 3, + Diagnostic::ResolveError { .. } => 4, + Diagnostic::UnitMismatch { .. } => 5, + Diagnostic::BranchConditionNotBool { .. } => 6, + Diagnostic::MagnitudeOutOfRange { .. } => 7, + Diagnostic::MalformedIntegerRangeFact { .. } => 8, + Diagnostic::NominalOpacityViolation { .. } => 9, + } + } + + /// Stable Layer-1 kind name for substrate mirrors and test predicates (Q6.5). + /// + /// Labels come only from [`LAYER1_DIAGNOSTIC_KIND_LABELS`]; an exhaustive per-variant index + /// match selects the row so new enum variants cannot compile without extending the const slice + /// and substrate closed sums. + pub fn layer1_kind_label(&self) -> &'static str { + LAYER1_DIAGNOSTIC_KIND_LABELS[self.layer1_declaration_index()] + } + + pub fn span(&self) -> &SourceSpan { + match self { + Diagnostic::TokenizerError { span, .. } + | Diagnostic::ParseError { span, .. } + | Diagnostic::TypeMismatch { span, .. } + | Diagnostic::ArityMismatch { span, .. } + | Diagnostic::ResolveError { span, .. } + | Diagnostic::UnitMismatch { span, .. } + | Diagnostic::BranchConditionNotBool { span, .. } + | Diagnostic::MagnitudeOutOfRange { span, .. } + | Diagnostic::MalformedIntegerRangeFact { span, .. } + | Diagnostic::NominalOpacityViolation { span, .. } => span, + } + } + + pub fn correction(&self) -> &Correction { + match self { + Diagnostic::TokenizerError { correction, .. } + | Diagnostic::ParseError { correction, .. } + | Diagnostic::TypeMismatch { correction, .. } + | Diagnostic::ArityMismatch { correction, .. } + | Diagnostic::ResolveError { correction, .. } + | Diagnostic::UnitMismatch { correction, .. } + | Diagnostic::BranchConditionNotBool { correction, .. } + | Diagnostic::MagnitudeOutOfRange { correction, .. } + | Diagnostic::MalformedIntegerRangeFact { correction, .. } + | Diagnostic::NominalOpacityViolation { correction, .. } => correction, + } + } + + pub fn message(&self) -> String { + match self { + Diagnostic::TokenizerError { message, .. } | Diagnostic::ParseError { message, .. } => { + message.clone() + } + Diagnostic::TypeMismatch { + expected, actual, .. + } => format!("expected {expected:?}, got {actual:?}"), + Diagnostic::ArityMismatch { + function, + expected, + actual, + .. + } => format!("{function} expected {expected}, got {actual}"), + Diagnostic::ResolveError { name, .. } => name.clone(), + Diagnostic::UnitMismatch { + operator, + parameter, + expected, + actual, + .. + } => format!( + "unit mismatch for operator `{operator}` on phantom parameter `{parameter}`: abelian-group closure requires matching units (expected {expected:?}, got {actual:?})" + ), + Diagnostic::BranchConditionNotBool { actual_type, .. } => match actual_type { + Some(ty) => format!("branch condition port is not Bool (got {ty:?})"), + None => "branch condition port is not Bool (type not resolved)".to_string(), + }, + Diagnostic::MagnitudeOutOfRange { + literal, + target, + range_min_inclusive, + range_max_inclusive, + expected, + .. + } => format!( + "integer literal `{literal}` is out of range for `{target}` (expected {range_min_inclusive}..={range_max_inclusive}, declared type {expected:?}); use a wider target" + ), + Diagnostic::MalformedIntegerRangeFact { message, .. } => message.clone(), + Diagnostic::NominalOpacityViolation { + declaration, + accessor, + .. + } => match accessor { + Some(accessor) => format!( + "nominal-opaque declaration `{declaration:?}` may only be structurally accessed through permitted accessor `{accessor:?}`" + ), + None => format!( + "nominal-opaque declaration `{declaration:?}` cannot be structurally descended without a permitted accessor" + ), + }, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DiagnosticStyleTarget { + Rust, + Go, + Python, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DiagnosticRenderError { + MissingLanguageSpec(&'static str), + MissingCleanEmissionContract(&'static str), + MalformedCleanEmissionContract { + declaration: DeclarationId, + detail: &'static str, + }, + MissingCorrectionStyle(&'static str), + MalformedCorrectionStyle { + declaration: DeclarationId, + detail: &'static str, + }, +} +#[derive(Debug, Clone, PartialEq, Eq)] +struct CorrectionStyleBinding { + indent_unit: String, + line_ending: String, + string_quote: String, + trailing_semicolon: bool, +} + +impl CorrectionStyleBinding { + fn build(dag: &Dag, declaration: DeclarationId) -> Result { + let fields = match dag.declaration(declaration).value_body.as_ref() { + Some(crate::dag::ValueBody::Structural { fields }) => fields, + _ => { + return Err(DiagnosticRenderError::MalformedCorrectionStyle { + declaration, + detail: "correction_style declaration must carry a Structural value_body", + }); + } + }; + Ok(Self { + indent_unit: require_string(fields, "indent_unit", declaration)?, + line_ending: require_string(fields, "line_ending", declaration)?, + string_quote: require_string(fields, "string_quote", declaration)?.replace("%Q", "\""), + trailing_semicolon: require_bool(fields, "trailing_semicolon", declaration)?, + }) + } +} + +pub fn render_diagnostic_for_target( + dag: &Dag, + target: DiagnosticStyleTarget, + diagnostic: &Diagnostic, +) -> Result { + let language_spec = language_spec_for_target(dag, target)?; + render_diagnostic_for_language_spec(dag, language_spec, diagnostic) +} + +pub fn render_diagnostic_for_language_spec( + dag: &Dag, + language_spec: DeclarationId, + diagnostic: &Diagnostic, +) -> Result { + let declaration = correction_style_for_language_spec(dag, language_spec)?; + let style = CorrectionStyleBinding::build(dag, declaration)?; + Ok(render_diagnostic_with_style(diagnostic, &style)) +} + +fn language_spec_for_target( + dag: &Dag, + target: DiagnosticStyleTarget, +) -> Result { + let (language_spec, missing_name) = match target { + DiagnosticStyleTarget::Rust => (dag.rust_language_spec(), "rust_language"), + DiagnosticStyleTarget::Go => (dag.go_language_spec(), "go_language"), + DiagnosticStyleTarget::Python => (dag.python_language_spec(), "python_language"), + }; + language_spec.ok_or(DiagnosticRenderError::MissingLanguageSpec(missing_name)) +} + +fn correction_style_for_language_spec( + dag: &Dag, + language_spec: DeclarationId, +) -> Result { + let clean_emission_decl = dag + .target_syntax_bundle_for_language(language_spec) + .map(|bundle| bundle.clean_emission_spec) + .ok_or(DiagnosticRenderError::MissingCleanEmissionContract( + "clean_emission.correction_style", + ))?; + let Some(ValueBody::Structural { fields }) = &dag.declaration(clean_emission_decl).value_body + else { + return Err(DiagnosticRenderError::MalformedCleanEmissionContract { + declaration: clean_emission_decl, + detail: "clean emission declaration must carry a structural value_body", + }); + }; + let value = fields + .iter() + .find(|(label, _)| label == "correction_style") + .map(|(_, value)| value) + .ok_or(DiagnosticRenderError::MissingCorrectionStyle( + "clean_emission.correction_style", + ))?; + match value { + FieldValue::Reference(declaration) => Ok(*declaration), + _ => Err(DiagnosticRenderError::MalformedCleanEmissionContract { + declaration: clean_emission_decl, + detail: "clean emission correction_style field must be a declaration reference", + }), + } +} + +fn render_diagnostic_with_style(diagnostic: &Diagnostic, style: &CorrectionStyleBinding) -> String { + let mut lines = vec![format!( + "ERROR at {}:{}-{}: {}", + diagnostic.span().file, + diagnostic.span().byte_start, + diagnostic.span().byte_end, + diagnostic.message() + )]; + if let Correction::LiveCorrection { .. } = diagnostic.correction() { + lines.push(format!("FIX: {}", diagnostic.correction().description())); + lines.push(render_correction_source(diagnostic.correction(), style)); + } else { + lines.push(format!( + "DEFERRED CORRECTION: {}", + diagnostic.correction().description() + )); + } + lines.join(&style.line_ending) +} + +fn render_correction_source(fix: &Correction, style: &CorrectionStyleBinding) -> String { + let Ok(witness) = fix.live_witness() else { + return String::new(); + }; + let suffix = if style.trailing_semicolon { ";" } else { "" }; + let normalized = witness.new_source.replace("\r\n", "\n").replace('\r', "\n"); + if !normalized.contains('\n') { + return format!( + "{}{}{}{}{}", + style.indent_unit, + style.string_quote, + escape_for_string_literal(&normalized, &style.string_quote), + style.string_quote, + suffix + ); + } + + let body = normalized + .split('\n') + .map(|line| { + format!( + "{}{}", + style.indent_unit, + escape_for_string_literal(line, &style.string_quote) + ) + }) + .collect::>() + .join(&style.line_ending); + format!( + "{}{}{}{}", + style.string_quote, style.line_ending, body, style.string_quote + ) + suffix +} + +fn escape_for_string_literal(source: &str, string_quote: &str) -> String { + let escaped = source.replace('\\', "\\\\"); + if string_quote.is_empty() { + return escaped; + } + escaped.replace(string_quote, &format!("\\{string_quote}")) +} + +fn require_string( + fields: &[(String, FieldValue)], + label: &'static str, + declaration: DeclarationId, +) -> Result { + let value = fields + .iter() + .find(|(field_label, _)| field_label == label) + .map(|(_, value)| value) + .ok_or(DiagnosticRenderError::MalformedCorrectionStyle { + declaration, + detail: "correction_style is missing a required String field", + })?; + match value { + FieldValue::Literal(crate::dag::LiteralBits::String(value)) => Ok(value.clone()), + _ => Err(DiagnosticRenderError::MalformedCorrectionStyle { + declaration, + detail: "correction_style String field must be a string literal", + }), + } +} + +fn require_bool( + fields: &[(String, FieldValue)], + label: &'static str, + declaration: DeclarationId, +) -> Result { + let value = fields + .iter() + .find(|(field_label, _)| field_label == label) + .map(|(_, value)| value) + .ok_or(DiagnosticRenderError::MalformedCorrectionStyle { + declaration, + detail: "correction_style is missing a required Bool field", + })?; + match value { + FieldValue::Literal(crate::dag::LiteralBits::Bool(value)) => Ok(*value), + _ => Err(DiagnosticRenderError::MalformedCorrectionStyle { + declaration, + detail: "correction_style Bool field must be a bool literal", + }), + } +} + +pub(crate) fn declaration_display_name(dag: &Dag, declaration: DeclarationId) -> String { + dag.declaration(declaration) + .name + .clone() + .unwrap_or_else(|| format!("declaration#{}", declaration.raw())) +} + +pub(crate) fn example_source_for_decl(dag: &Dag, declaration: DeclarationId) -> Option { + example_source_for_decl_inner(dag, declaration, 0) +} + +fn example_source_for_decl_inner( + dag: &Dag, + declaration: DeclarationId, + depth: usize, +) -> Option { + if depth >= 8 { + return None; + } + if dag.declaration(declaration).refinement.is_some() { + return None; + } + if let Some(example) = builtin_example_source(dag, declaration, depth) { + return Some(example); + } + let decl = dag.declaration(declaration); + match &decl.connective { + TypeConnective::Atom(AtomPayload::ResolvedByStructure(next)) + | TypeConnective::Atom(AtomPayload::ResolvedByName(next)) => { + example_source_for_decl_inner(dag, *next, depth + 1) + } + TypeConnective::Instantiation { template, .. } => { + example_source_for_decl_inner(dag, *template, depth + 1) + } + TypeConnective::Conj { children } => { + let fields: Option> = children + .iter() + .map(|field| { + Some(format!( + "{}: {}", + field.label, + example_source_for_decl_inner(dag, field.ty, depth + 1)? + )) + }) + .collect(); + Some(format!("{{ {} }}", fields?.join(", "))) + } + TypeConnective::Disj { variants } => variants + .iter() + .find_map(|variant| render_variant_witness(dag, variant, depth + 1)), + TypeConnective::Cardinality(p) if p.bound() == CardinalityBound::AtMostOne => { + Some("None".to_string()) + } + _ => None, + } +} + +fn builtin_example_source(dag: &Dag, declaration: DeclarationId, depth: usize) -> Option { + if dag.int_shape().is_some_and(|shape| { + decl_matches_example_identity(dag, declaration, shape.declaration, depth) + }) { + return Some("1".to_string()); + } + if dag.bool_shape().is_some_and(|shape| { + decl_matches_example_identity(dag, declaration, shape.declaration, depth) + }) { + return Some("true".to_string()); + } + if dag.string_shape().is_some_and(|shape| { + decl_matches_example_identity(dag, declaration, shape.declaration, depth) + }) { + return Some("\"x\"".to_string()); + } + if dag.list_template().is_some_and(|list_template| { + decl_matches_example_identity(dag, declaration, list_template, depth) + }) { + return Some("[]".to_string()); + } + None +} + +fn canonical_decl_for_example( + dag: &Dag, + declaration: DeclarationId, + depth: usize, +) -> Option { + if depth >= 8 { + return None; + } + match &dag.declaration(declaration).connective { + TypeConnective::Atom(AtomPayload::ResolvedByStructure(next)) + | TypeConnective::Atom(AtomPayload::ResolvedByName(next)) => { + canonical_decl_for_example(dag, *next, depth + 1) + } + TypeConnective::Instantiation { template, .. } => { + canonical_decl_for_example(dag, *template, depth + 1) + } + _ => Some(declaration), + } +} + +fn decl_matches_example_identity( + dag: &Dag, + actual: DeclarationId, + expected: DeclarationId, + depth: usize, +) -> bool { + canonical_decl_for_example(dag, actual, depth) + == canonical_decl_for_example(dag, expected, depth) +} + +fn render_variant_witness(dag: &Dag, variant: &Field, depth: usize) -> Option { + if dag.declaration(variant.ty).refinement.is_some() { + return None; + } + let TypeConnective::Conj { children } = &dag.declaration(variant.ty).connective else { + return None; + }; + if children.is_empty() { + return Some(variant.label.clone()); + } + // Constructor invocation in the current `.dag` surface is + // positional (`Variant(arg0, arg1)`), even when the payload + // declaration's fields are named. Preserve declaration-derived + // child order here, but do not invent an unsupported + // `Variant { field: value }` surface until lowering accepts that + // shape structurally. + let payload: Option> = children + .iter() + .map(|field| example_source_for_decl_inner(dag, field.ty, depth + 1)) + .collect(); + Some(format!("{}({})", variant.label, payload?.join(", "))) +} + +pub(crate) fn witness_correction_for_decl( + dag: &Dag, + declaration: DeclarationId, + span: SourceSpan, + description: impl Into, +) -> Option { + if dag.declaration(declaration).refinement.is_some() { + return None; + } + Some(Correction::live( + description, + span, + example_source_for_decl(dag, declaration)?, + )) +} + +/// Opaque, validated witness to a path in the substrate +/// `bootstrap_authority` set declared by +/// `src/v3/std/bootstrap_authority.dag`. Held internally as a +/// `&'static str` because the only minters are the bootstrap loader +/// (`bootstrap_regen_fresh::parse_fixture`) and bootstrap kernel-patch +/// helpers (`bootstrap::patch_kernel_bool_boolean_algebra_inhabits`), +/// both of which iterate the `&'static`-keyed authority arrays +/// produced by `build.rs` (`STAGED_FILES`/`V3_SPECS`/`COMPILER_FILES`/ +/// `EXTDEPS_FILES`) — derivations of the same substrate +/// `bootstrap_authority` set. +/// +/// The constructor is deliberately `pub(crate)`. Consumers receive +/// minted keys through [`DiagnosticAttribution::BootstrapAuthority`] +/// and dispatch on witness identity (equality / hash). They MUST NOT +/// rebuild a `BootstrapAuthorityKey` from a `SourceSpan.file` string +/// to recover attribution — that would defeat the witness contract. +/// `path()` is exposed for diagnostic display only. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct BootstrapAuthorityKey(&'static str); + +impl BootstrapAuthorityKey { + /// Mint a key from a `&'static str` known to be a row in the + /// substrate `bootstrap_authority` set. Bootstrap-only constructor + /// — see type-level doc for the witness contract. + pub(crate) fn new(path: &'static str) -> Self { + Self(path) + } + + /// Typed witness for the kernel-`Bool` `inhabits` patch authority + /// (`dsl/std/types.dag` row of the substrate `bootstrap_authority` + /// set, classified `StdAuthority`). Encapsulates the path-string so + /// `bootstrap.rs` consumers do not name `dsl/std/types.dag` directly + /// — audit-row #2 retirement (bootstrap.rs slice 1 of 2). Reviewers + /// must treat new `for_*` accessors as additions to the witness + /// surface; each one represents a hand-Rust authority site. + pub(crate) fn for_kernel_bool() -> Self { + Self::new("dsl/std/types.dag") + } + + /// Typed witness for the pipeline-authority row + /// (`src/v3/compiler/pipeline.dag` of the substrate + /// `bootstrap_authority` set, classified `CompilerAuthority`). + /// Encapsulates the path-string so `bootstrap.rs` consumers do not + /// name `src/v3/compiler/pipeline.dag` directly — audit-row #6 + /// retirement (bootstrap.rs slice 1 of 2). + pub(crate) fn for_pipeline_authority() -> Self { + Self::new("src/v3/compiler/pipeline.dag") + } + + /// Canonical bootstrap-authority path. **Display only.** Consumers + /// dispatching on attribution must use witness equality, not this + /// string, to decide bootstrap membership; a string compare against + /// `SourceSpan.file` is precisely the path-string bridge this type + /// dissolves. + /// + /// **Egress-only contract.** This accessor is intentionally a + /// one-way door: a consumer may read the path for diagnostic + /// rendering, but the only way *back* to a `BootstrapAuthorityKey` + /// witness is the `pub(crate)` [`Self::new`] constructor that the + /// bootstrap loader owns. Round-tripping `path()` through some + /// other public constructor is a contract-violation that the type + /// system cannot block on its own; reviewers should treat any new + /// `pub fn from_path` / `From<&str>` impl on this type as the same + /// path-string bridge re-introduced and reject it. + pub fn path(&self) -> &'static str { + self.0 + } +} + +/// Where a diagnostic originated in the bootstrap-vs-user dimension. +/// +/// `Unattributed` is the default for diagnostics raised against +/// user-compile sources or against not-yet-classified internal +/// failures. `BootstrapAuthority` carries an opaque witness that the +/// diagnostic was attached while loading or patching one of the +/// substrate `bootstrap_authority` rows. Verification consumers (e.g. +/// the row-82 `diagnostics_empty_after_bootstrap` ratchet) dispatch on +/// this discriminant rather than scanning `Diagnostic.span().file`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DiagnosticAttribution { + Unattributed, + BootstrapAuthority(BootstrapAuthorityKey), +} + +impl DiagnosticAttribution { + /// `true` iff this diagnostic was attached against a known + /// bootstrap-authority row. Verification surfaces should prefer + /// this over `span.file == "src/v3/std/..."` predicates. + pub fn is_bootstrap(&self) -> bool { + matches!(self, Self::BootstrapAuthority(_)) + } + + /// Borrow the bootstrap-authority witness, when present. + pub fn as_bootstrap_authority(&self) -> Option<&BootstrapAuthorityKey> { + match self { + Self::BootstrapAuthority(key) => Some(key), + Self::Unattributed => None, + } + } +} + +#[derive(Debug, Default, Clone)] +pub struct DiagnosticTable { + entries: HashMap, +} + +impl DiagnosticTable { + pub fn new() -> Self { + Self { + entries: HashMap::new(), + } + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + pub fn contains(&self, port: PortId) -> bool { + self.entries.contains_key(&port) + } + + pub fn get(&self, port: PortId) -> Option<&Diagnostic> { + self.entries.get(&port).map(|(d, _)| d) + } + + /// Borrow the structural attribution recorded alongside a + /// diagnostic. `Some(Unattributed)` for ordinary user-compile + /// failures; `Some(BootstrapAuthority(_))` for diagnostics attached + /// while loading or patching a substrate bootstrap-authority row. + pub fn attribution(&self, port: PortId) -> Option<&DiagnosticAttribution> { + self.entries.get(&port).map(|(_, a)| a) + } + + pub fn len(&self) -> usize { + self.entries.len() + } + + /// Iterate `(port, diagnostic)` pairs. Used by tests and callers + /// that need to scan all diagnostics for a specific kind. For + /// attribution-aware iteration use [`Self::iter_attributed`]. + pub fn iter(&self) -> impl Iterator { + self.entries.iter().map(|(p, (d, _))| (*p, d)) + } + + /// Iterate `(port, diagnostic, attribution)` triples. Verification + /// consumers that need to count or filter by bootstrap attribution + /// without touching `SourceSpan.file` use this surface. + pub fn iter_attributed( + &self, + ) -> impl Iterator { + self.entries.iter().map(|(p, (d, a))| (*p, d, a)) + } + + /// Insert a diagnostic entry for a port. pub(crate) because the + /// only callers are Dag::mark_unresolved* (which atomically also + /// clears the port's value_type) and should never be called + /// independently from outside the crate. + pub(crate) fn insert( + &mut self, + port: PortId, + diagnostic: Diagnostic, + attribution: DiagnosticAttribution, + ) { + self.entries.insert(port, (diagnostic, attribution)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::TypeShape; + + fn layer1_diagnostic_examples_in_declaration_order() -> Vec { + let span = SourceSpan::new("layer1_kind_label_ratchets.v3", 0, 0); + let ty = TypeShape::new(DeclarationId::test_raw(0)); + vec![ + Diagnostic::TokenizerError { + message: String::new(), + span: span.clone(), + correction: Correction::deferred_for_diagnostic_class("TokenizerError"), + }, + Diagnostic::ParseError { + message: String::new(), + span: span.clone(), + correction: Correction::deferred_for_diagnostic_class("ParseError"), + }, + Diagnostic::TypeMismatch { + expected: ty, + actual: ty, + span: span.clone(), + correction: Correction::deferred_for_diagnostic_class("TypeMismatch"), + }, + Diagnostic::ArityMismatch { + function: String::new(), + expected: 0, + actual: 0, + span: span.clone(), + correction: Correction::deferred_for_diagnostic_class("ArityMismatch"), + }, + Diagnostic::ResolveError { + name: String::new(), + span: span.clone(), + correction: Correction::deferred_for_diagnostic_class("ResolveError"), + }, + Diagnostic::UnitMismatch { + operator: String::new(), + parameter: String::new(), + expected: ty, + actual: ty, + span: span.clone(), + correction: Correction::deferred_for_diagnostic_class("UnitMismatch"), + }, + Diagnostic::BranchConditionNotBool { + port: PortId::test_raw(0), + actual_type: None, + span: span.clone(), + correction: Correction::deferred_for_diagnostic_class("BranchConditionNotBool"), + }, + Diagnostic::MagnitudeOutOfRange { + literal: String::new(), + target: String::new(), + range_min_inclusive: String::new(), + range_max_inclusive: String::new(), + expected: ty, + span: span.clone(), + correction: Correction::deferred_for_diagnostic_class("MagnitudeOutOfRange"), + }, + Diagnostic::MalformedIntegerRangeFact { + message: String::new(), + span: span.clone(), + correction: Correction::deferred_for_diagnostic_class("MalformedIntegerRangeFact"), + }, + Diagnostic::NominalOpacityViolation { + declaration: DeclarationId::test_raw(0), + accessor: None, + span, + correction: Correction::deferred_for_diagnostic_class("NominalOpacityViolation"), + }, + ] + } + + #[test] + fn layer1_ordered_labels_match_layer1_kind_label() { + let examples = layer1_diagnostic_examples_in_declaration_order(); + assert_eq!( + examples.len(), + LAYER1_DIAGNOSTIC_KIND_LABELS.len(), + "declaration-order fixture must list every Diagnostic variant exactly once" + ); + let mut seen = std::collections::HashSet::new(); + for (i, diag) in examples.iter().enumerate() { + let idx = diag.layer1_declaration_index(); + assert_eq!( + idx, i, + "fixture must stay in declaration order and match layer1_declaration_index()" + ); + let label = diag.layer1_kind_label(); + assert_eq!(label, LAYER1_DIAGNOSTIC_KIND_LABELS[i]); + assert!(seen.insert(label), "duplicate layer1 label: {label}"); + } + } + + #[test] + fn apply_correction_replaces_the_requested_span() { + let updated = apply_correction( + "let x: Int = 1\n", + &Correction::live( + "replace the literal", + SourceSpan::new("apply_fix.v3", 13, 14), + "2", + ), + ) + .expect("correction should apply"); + assert_eq!(updated, "let x: Int = 2\n"); + } + + #[test] + fn apply_correction_uses_span_offsets_not_file_participation() { + let updated = apply_correction( + "let x: Int = 1\n", + &Correction::live( + "replace the literal", + SourceSpan::new("expected.v3", 13, 14), + "2", + ), + ) + .expect("correction application is offset-bounded, not gated by a separate file string"); + assert_eq!(updated, "let x: Int = 2\n"); + } + + #[test] + fn apply_correction_rejects_non_char_boundaries() { + let source = "let x = \"é\"\n"; + let accent = source.find('é').expect("fixture contains accent") as u32; + let error = apply_correction( + source, + &Correction::live( + "split the accent", + SourceSpan::new("utf8_fix.v3", accent, accent + 1), + "e", + ), + ) + .expect_err("mid-codepoint correction must fail closed"); + assert_eq!( + error, + CorrectionApplyError::NonCharBoundary { offset: accent + 1 } + ); + } + + #[test] + fn apply_correction_and_reparse_surfaces_parse_failures() { + let error = apply_correction_and_reparse( + "let x: Int = 1\n", + "broken_fix.v3", + &Correction::live( + "make the expression malformed", + SourceSpan::new("broken_fix.v3", 13, 14), + ")", + ), + ) + .expect_err("malformed correction should fail closed"); + assert!( + matches!(error, CorrectionValidationError::Parse(_)), + "expected parse failure carrier, got {error:?}" + ); + } + + #[test] + fn clean_emission_contracts_reference_named_correction_styles() { + let dag = Dag::new(); + let assert_reference = + |clean_decl: DeclarationId, expected_style_name: &str, expected_name: &str| { + let fields = match dag + .declaration(clean_decl) + .value_body + .as_ref() + .expect("clean emission carries value_body") + { + crate::dag::ValueBody::Structural { fields } => fields, + other => panic!("clean emission must be structural, got {other:?}"), + }; + let value = fields + .iter() + .find(|(label, _)| label == "correction_style") + .map(|(_, value)| value) + .expect("correction_style field exists"); + match value { + FieldValue::Reference(actual) => assert_eq!( + dag.declaration(*actual).name.as_deref(), + Some(expected_style_name), + "{expected_name} should point at its named correction style" + ), + other => panic!("correction_style must be a Reference, got {other:?}"), + } + }; + assert_reference( + dag.rust_clean_emission_spec().expect("rust clean emission"), + "rust_correction_style", + "rust_clean_emission", + ); + assert_reference( + dag.go_clean_emission_spec().expect("go clean emission"), + "go_correction_style", + "go_clean_emission", + ); + assert_reference( + dag.python_clean_emission_spec() + .expect("python clean emission"), + "python_correction_style", + "python_clean_emission", + ); + } + + #[test] + fn target_syntax_bundles_pair_each_language_with_its_clean_emission_contract() { + let dag = Dag::new(); + let assert_bundle = |language_spec: DeclarationId, + expected_clean_emission: DeclarationId| { + let bundle = dag + .target_syntax_bundle_for_language(language_spec) + .expect("bundle should exist for cached language spec"); + assert_eq!(bundle.language_spec, language_spec); + assert_eq!(bundle.clean_emission_spec, expected_clean_emission); + }; + assert_bundle( + dag.rust_language_spec().expect("rust language"), + dag.rust_clean_emission_spec().expect("rust clean emission"), + ); + assert_bundle( + dag.go_language_spec().expect("go language"), + dag.go_clean_emission_spec().expect("go clean emission"), + ); + assert_bundle( + dag.python_language_spec().expect("python language"), + dag.python_clean_emission_spec() + .expect("python clean emission"), + ); + } + + #[test] + fn example_source_for_decl_uses_cached_structural_identities_not_std_names() { + let mut dag = Dag::new(); + let int_decl = dag.int_shape().expect("int shape").declaration; + let bool_decl = dag.bool_shape().expect("bool shape").declaration; + let string_decl = dag.string_shape().expect("string shape").declaration; + let list_decl = dag.list_template().expect("list template"); + + dag.declaration_mut(int_decl).name = Some("WholeNumber".to_string()); + dag.declaration_mut(bool_decl).name = Some("TruthValue".to_string()); + dag.declaration_mut(string_decl).name = Some("Text".to_string()); + dag.declaration_mut(list_decl).name = Some("Sequence".to_string()); + + let list_instantiation = dag.alloc_declaration_id(); + dag.push_declaration(crate::dag::Declaration { + id: list_instantiation, + name: Some("SequenceOfInt".to_string()), + connective: TypeConnective::Instantiation { + template: list_decl, + arguments: Vec::new(), + }, + type_params: Vec::new(), + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: None, + span: SourceSpan::new("diagnostics_test.v3", 0, 1), + }); + + assert_eq!( + example_source_for_decl(&dag, int_decl).as_deref(), + Some("1") + ); + assert_eq!( + example_source_for_decl(&dag, bool_decl).as_deref(), + Some("true") + ); + assert_eq!( + example_source_for_decl(&dag, string_decl).as_deref(), + Some("\"x\"") + ); + assert_eq!( + example_source_for_decl(&dag, list_decl).as_deref(), + Some("[]") + ); + assert_eq!( + example_source_for_decl(&dag, list_instantiation).as_deref(), + Some("[]") + ); + } + + #[test] + fn example_source_for_decl_fails_closed_on_refined_record_children() { + let mut dag = Dag::new(); + let int_decl = dag.int_shape().expect("int shape").declaration; + let bool_decl = dag.bool_shape().expect("bool shape").declaration; + + let refined_child = dag.alloc_declaration_id(); + dag.push_declaration(crate::dag::Declaration { + id: refined_child, + name: Some("BigInt".to_string()), + connective: TypeConnective::Atom(AtomPayload::ResolvedByStructure(int_decl)), + type_params: Vec::new(), + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: Some(bool_decl), + nominal_opacity: None, + span: SourceSpan::new("diagnostics_test.v3", 0, 1), + }); + + let record_decl = dag.alloc_declaration_id(); + dag.push_declaration(crate::dag::Declaration { + id: record_decl, + name: Some("NeedsBig".to_string()), + connective: TypeConnective::Conj { + children: vec![Field { + label: "value".to_string(), + ty: refined_child, + }], + }, + type_params: Vec::new(), + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: None, + span: SourceSpan::new("diagnostics_test.v3", 0, 1), + }); + + assert_eq!(example_source_for_decl(&dag, record_decl), None); + } + + #[test] + fn example_source_for_decl_fails_closed_on_refined_sum_payloads() { + let mut dag = Dag::new(); + let int_decl = dag.int_shape().expect("int shape").declaration; + let bool_decl = dag.bool_shape().expect("bool shape").declaration; + + let refined_child = dag.alloc_declaration_id(); + dag.push_declaration(crate::dag::Declaration { + id: refined_child, + name: Some("BigInt".to_string()), + connective: TypeConnective::Atom(AtomPayload::ResolvedByStructure(int_decl)), + type_params: Vec::new(), + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: Some(bool_decl), + nominal_opacity: None, + span: SourceSpan::new("diagnostics_test.v3", 0, 1), + }); + + let payload_decl = dag.alloc_declaration_id(); + dag.push_declaration(crate::dag::Declaration { + id: payload_decl, + name: None, + connective: TypeConnective::Conj { + children: vec![Field { + label: "value".to_string(), + ty: refined_child, + }], + }, + type_params: Vec::new(), + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: None, + span: SourceSpan::new("diagnostics_test.v3", 0, 1), + }); + + let sum_decl = dag.alloc_declaration_id(); + dag.push_declaration(crate::dag::Declaration { + id: sum_decl, + name: Some("MaybeBig".to_string()), + connective: TypeConnective::Disj { + variants: vec![Field { + label: "Some".to_string(), + ty: payload_decl, + }], + }, + type_params: Vec::new(), + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: None, + span: SourceSpan::new("diagnostics_test.v3", 0, 1), + }); + + assert_eq!(example_source_for_decl(&dag, sum_decl), None); + } + + /// Regression: happy-path payloaded disjunct. Fills the coverage gap + /// the PR #554 loop-health meta-review called out — the existing + /// witness tests cover unpayloaded primitives and fail-closed refined + /// payloads, but no test pinned "Disj with a positional-payload variant + /// renders as `Variant(witness_arg0, witness_arg1, …)`". The walk goes + /// through `render_variant_witness` → `example_source_for_decl_inner` + /// on each payload field, so the assertion exercises the full + /// Disj → Conj → primitive recursion path structurally. + #[test] + fn example_source_for_decl_renders_positional_payload_variant() { + let mut dag = Dag::new(); + let int_decl = dag.int_shape().expect("int shape").declaration; + let bool_decl = dag.bool_shape().expect("bool shape").declaration; + + // Positional payload: fields `_0: Int`, `_1: Bool` — mirrors the + // shape lowering produces for `Cons(Int, Bool)` before any named- + // field surface grammar lands. + let payload_decl = dag.alloc_declaration_id(); + dag.push_declaration(crate::dag::Declaration { + id: payload_decl, + name: None, + connective: TypeConnective::Conj { + children: vec![ + Field { + label: "_0".to_string(), + ty: int_decl, + }, + Field { + label: "_1".to_string(), + ty: bool_decl, + }, + ], + }, + type_params: Vec::new(), + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: None, + span: SourceSpan::new("diagnostics_test.v3", 0, 1), + }); + + let sum_decl = dag.alloc_declaration_id(); + dag.push_declaration(crate::dag::Declaration { + id: sum_decl, + name: Some("Pair".to_string()), + connective: TypeConnective::Disj { + variants: vec![Field { + label: "Paired".to_string(), + ty: payload_decl, + }], + }, + type_params: Vec::new(), + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: None, + span: SourceSpan::new("diagnostics_test.v3", 0, 1), + }); + + assert_eq!( + example_source_for_decl(&dag, sum_decl).as_deref(), + Some("Paired(1, true)"), + "payloaded variant witness must recurse structurally through each payload field" + ); + } + + #[test] + fn render_rust_diagnostic_uses_rust_correction_style() { + let dag = Dag::new(); + let rendered = render_diagnostic_for_target( + &dag, + DiagnosticStyleTarget::Rust, + &Diagnostic::ResolveError { + name: "field `c` does not exist on `Point`".to_string(), + span: SourceSpan::new("field.v3", 12, 19), + correction: Correction::live( + "did you mean `point.a`?", + SourceSpan::new("field.v3", 12, 19), + "point.a", + ), + }, + ) + .expect("render"); + assert!(rendered.contains("FIX: did you mean `point.a`?")); + assert!(rendered.contains("\n \"point.a\";")); + } + + #[test] + fn render_deferred_correction_does_not_emit_fix_header() { + let dag = Dag::new(); + let rendered = render_diagnostic_for_target( + &dag, + DiagnosticStyleTarget::Rust, + &Diagnostic::ParseError { + message: "not yet live-correctable".to_string(), + span: SourceSpan::new("deferred.v3", 0, 1), + correction: Correction::deferred_for_diagnostic_class("ParseError"), + }, + ) + .expect("render"); + assert!(rendered.contains("ERROR at deferred.v3:0-1")); + assert!( + !rendered.contains("FIX (option"), + "deferred corrections are explicit residual carriers, not user-facing fixes: {rendered}" + ); + assert!( + rendered.contains("DEFERRED CORRECTION: ParseError does not yet compute"), + "deferred corrections should remain visible without rendering as applyable fixes: {rendered}" + ); + } + + #[test] + fn render_go_diagnostic_uses_go_correction_style() { + let dag = Dag::new(); + let rendered = render_diagnostic_for_target( + &dag, + DiagnosticStyleTarget::Go, + &Diagnostic::ResolveError { + name: "field `c` does not exist on `Point`".to_string(), + span: SourceSpan::new("field.v3", 12, 19), + correction: Correction::live( + "did you mean `point.a`?", + SourceSpan::new("field.v3", 12, 19), + "point.a", + ), + }, + ) + .expect("render"); + assert!(rendered.contains("FIX: did you mean `point.a`?")); + assert!(rendered.contains("\n\t\"point.a\"")); + assert!(!rendered.contains("\n\t\"point.a\";")); + } + + #[test] + fn render_python_diagnostic_uses_python_correction_style() { + let dag = Dag::new(); + let rendered = render_diagnostic_for_target( + &dag, + DiagnosticStyleTarget::Python, + &Diagnostic::ResolveError { + name: "field `c` does not exist on `Point`".to_string(), + span: SourceSpan::new("field.v3", 12, 19), + correction: Correction::live( + "did you mean `point.a`?", + SourceSpan::new("field.v3", 12, 19), + "point.a", + ), + }, + ) + .expect("render"); + assert!(rendered.contains("FIX: did you mean `point.a`?")); + assert!(rendered.contains("\n \"point.a\"")); + assert!(!rendered.contains("\n \"point.a\";")); + } + + #[test] + fn render_rust_diagnostic_escapes_quotes_and_backslashes_in_fix_source() { + let dag = Dag::new(); + let rendered = render_diagnostic_for_target( + &dag, + DiagnosticStyleTarget::Rust, + &Diagnostic::ResolveError { + name: "field `path` does not exist on `Config`".to_string(), + span: SourceSpan::new("field.v3", 12, 19), + correction: Correction::live( + "did you mean `config.path`?", + SourceSpan::new("field.v3", 12, 19), + "config[\"path\\\\name\"]", + ), + }, + ) + .expect("render"); + assert!(rendered.contains("\n \"config[\\\"path\\\\\\\\name\\\"]\";")); + } + + #[test] + fn render_multiline_fix_escapes_quotes_and_backslashes_in_each_line() { + let dag = Dag::new(); + let rendered = render_diagnostic_for_target( + &dag, + DiagnosticStyleTarget::Python, + &Diagnostic::ResolveError { + name: "field `path` does not exist on `Config`".to_string(), + span: SourceSpan::new("field.v3", 12, 19), + correction: Correction::live( + "did you mean `config.path`?", + SourceSpan::new("field.v3", 12, 19), + "config[\n\"path\\\\name\"\n]", + ), + }, + ) + .expect("render"); + assert!(rendered.contains("\"\n config[\n \\\"path\\\\\\\\name\\\"\n ]\"")); + } + + #[test] + fn diagnostic_attribution_default_is_unattributed_and_distinguishes_bootstrap() { + let key_a = BootstrapAuthorityKey::new("src/v3/std/induction.dag"); + let key_b = BootstrapAuthorityKey::new("dsl/std/types.dag"); + + let unattributed = DiagnosticAttribution::Unattributed; + let bootstrap_a = DiagnosticAttribution::BootstrapAuthority(key_a.clone()); + let bootstrap_a_again = DiagnosticAttribution::BootstrapAuthority(key_a.clone()); + let bootstrap_b = DiagnosticAttribution::BootstrapAuthority(key_b.clone()); + + assert!(!unattributed.is_bootstrap()); + assert_eq!(unattributed.as_bootstrap_authority(), None); + + assert!(bootstrap_a.is_bootstrap()); + assert_eq!(bootstrap_a.as_bootstrap_authority(), Some(&key_a)); + + // Witness identity (==) is the consumer dispatch path — no + // `span.file` string compare. + assert_eq!(bootstrap_a, bootstrap_a_again); + assert_ne!(bootstrap_a, bootstrap_b); + assert_ne!(bootstrap_a, unattributed); + } + + #[test] + fn diagnostic_table_round_trips_bootstrap_attribution_per_port() { + // Drive through the public dag attach surface so we exercise + // the same biconditional (`Unresolved port iff diagnostic`) as + // production. `attach_diagnostic` is `pub(crate)`, so this + // test stays in-crate. + let mut dag = Dag::empty(); + let key = BootstrapAuthorityKey::new("src/v3/std/python_method_template_contracts.dag"); + let span = SourceSpan::new("src/v3/std/python_method_template_contracts.dag", 0, 0); + + dag.attach_diagnostic(Diagnostic::ResolveError { + name: "user".to_string(), + span: span.clone(), + correction: Correction::deferred_for_diagnostic_class("ResolveError"), + }); + dag.attach_bootstrap_diagnostic( + key.clone(), + Diagnostic::ResolveError { + name: "bootstrap".to_string(), + span, + correction: Correction::deferred_for_diagnostic_class("ResolveError"), + }, + ); + + let mut unattr_seen = 0usize; + let mut bootstrap_seen = 0usize; + for (_port, diag, attribution) in dag.diagnostics().iter_attributed() { + let Diagnostic::ResolveError { name, .. } = diag else { + panic!("unexpected diagnostic kind: {diag:?}"); + }; + match (name.as_str(), attribution) { + ("user", DiagnosticAttribution::Unattributed) => unattr_seen += 1, + ("bootstrap", DiagnosticAttribution::BootstrapAuthority(k)) => { + assert_eq!(k, &key); + bootstrap_seen += 1; + } + other => panic!("unexpected (name, attribution) pair: {other:?}"), + } + } + assert_eq!(unattr_seen, 1); + assert_eq!(bootstrap_seen, 1); + + let bootstrap_count = dag + .diagnostics() + .iter_attributed() + .filter(|(_, _, a)| a.is_bootstrap()) + .count(); + assert_eq!(bootstrap_count, 1); + } +} diff --git a/tools/pb0_cycle4_emit_templates/dimension.rs.in b/tools/pb0_cycle4_emit_templates/dimension.rs.in new file mode 100644 index 00000000000..28427c626c4 --- /dev/null +++ b/tools/pb0_cycle4_emit_templates/dimension.rs.in @@ -0,0 +1,539 @@ +//! DB-3 dimension analysis — Lane 2 Stage 2f. +//! +//! Type authority: `src/v3/std/dimensions.dag`, spine order: +//! [`behavior_spine_in_node_order`] (`src/v3/std/workflows.dag`). +//! +//! Symbolic-cost dimension is the first migrated lens: it reuses +//! [`crate::lens_cost_symbolic::symbolic_cost_of`] for per-Behavior +//! witnesses and reports the composed carrier at `workflow_root`'s result +//! port (same query the cost lens answers today). +//! +//! Witnesses are collected only for behaviors **reachable backward** from +//! `workflow_root`'s result port (plus `workflow_root` itself). The symbolic +//! cost lens fold only resolves costs along producer chains in `Dag.nodes` +//! construction order; bootstrap declarations outside the active workflow +//! slice routinely carry `Lookup::Miss` on their result ports even though the +//! user program is well-costed — whole-DAG iteration would false-fail. + +use std::collections::HashSet; + +use crate::dag::{Behavior, Dag, NodeId, PortId, SymbolicCost}; +use crate::diagnostics::{Correction, Diagnostic, SourceSpan}; +use crate::lens_cost_symbolic::{symbolic_cost_of, SymbolicCostLookup}; + +fn behavior_result_port(b: &Behavior) -> PortId { + match b { + Behavior::Value(v) => v.result_port(), + Behavior::Transform(t) => t.result_port(), + Behavior::Branch(br) => br.result_port(), + Behavior::Loop(l) => l.result_port(), + Behavior::Bind(bind) => bind.result_port(), + } +} + +fn behavior_span(at: &Behavior) -> SourceSpan { + match at { + Behavior::Value(v) => v.span.clone(), + Behavior::Transform(t) => t.span.clone(), + Behavior::Branch(b) => b.span.clone(), + Behavior::Loop(l) => l.span.clone(), + Behavior::Bind(bind) => bind.span.clone(), + } +} + +/// Evidence partition — mirrors `Witness` in `std/dimensions.dag`. +#[derive(Debug, Clone)] +pub enum Witness { + Inhabits(C), + Violates { reason: String, at: Behavior }, +} + +/// Report carrier — mirrors `DimensionReport` in `std/dimensions.dag`. +/// +/// 🟢 TERMINAL (aggregate from DB-3 dimension evaluation; see `dimensions.dag`). +/// Pass/fail partition: success carries `composed`; failure carries `violations` +/// and must not fabricate a carrier when witnesses violate or root composition +/// misses (R2 fail-closed). +#[derive(Debug, Clone)] +pub enum DimensionReport { + DimensionOk { + dimension_name: String, + composed: C, + witnesses: Vec>, + }, + DimensionFail { + dimension_name: String, + violations: Vec, + witnesses: Vec>, + }, +} + +/// Same `Dag.nodes` order as `lenses/cost.dag::compute_symbolic_costs` / +/// `std/workflows.dag::behavior_spine`. +pub fn behavior_spine_in_node_order(d: &Dag) -> &[Behavior] { + d.nodes() +} + +/// Behaviors on the backward dataflow slice from `workflow_root`'s result +/// port, including `workflow_root` itself (the bind / root may not appear as +/// the `produced_by` target of its own result port). +fn workflow_reachable_behavior_ids(d: &Dag, workflow_root: NodeId) -> HashSet { + let mut visited_nodes = HashSet::new(); + let mut visited_ports: HashSet = HashSet::new(); + let mut frontier: Vec = Vec::new(); + + let root_behavior = d.node(workflow_root); + frontier.push(behavior_result_port(root_behavior)); + + while let Some(port) = frontier.pop() { + if !visited_ports.insert(port) { + continue; + } + let Some(producer) = d.port_opt(&port).and_then(|p| p.produced_by) else { + continue; + }; + expand_behavior_backward(d, producer, &mut frontier, &mut visited_nodes); + } + + // Always include the workflow root itself for dimension witnesses (e.g. a + // `Bind` is never the `produced_by` of its own `value` port). Do **not** + // seed `workflow_root` into `visited_nodes` before the walk: when the root + // is a `Transform`, its result port's producer is that same node — an early + // insert would make `expand_behavior_backward` return without enqueueing the + // transform's inputs, shrinking the slice to `{root}` only. + visited_nodes.insert(workflow_root); + visited_nodes +} + +fn expand_behavior_backward( + d: &Dag, + node: NodeId, + frontier: &mut Vec, + visited_nodes: &mut HashSet, +) { + if !visited_nodes.insert(node) { + return; + } + let Some(behavior) = d.node_opt(&node) else { + return; + }; + match behavior { + Behavior::Value(_) => {} + Behavior::Transform(transform) => { + for input in &transform.inputs { + frontier.push(*input); + } + } + Behavior::Branch(branch) => { + frontier.push(branch.input); + for path in &branch.paths { + expand_behavior_backward(d, path.body, frontier, visited_nodes); + frontier.push(path.output); + } + } + Behavior::Loop(loop_node) => { + frontier.push(loop_node.source); + frontier.push(loop_node.init); + if let Some(count) = loop_node.bound.count_port() { + frontier.push(count); + } + expand_behavior_backward(d, loop_node.body, frontier, visited_nodes); + } + Behavior::Bind(bind) => { + for param in &bind.params { + frontier.push(*param); + } + frontier.push(bind.value); + } + } +} + +/// Symbolic-cost dimension analysis — DB-3 dispatch over the cost lens algebra. +/// +/// On success ([`DimensionReport::DimensionOk`]), `composed` is the asymptotic +/// bound at `workflow_root`'s result port. Witnesses walk every reachable +/// [`Behavior`] in [`behavior_spine_in_node_order`] (construction order, +/// filtered to the backward slice from the workflow root). Failure is only +/// [`DimensionReport::DimensionFail`]: there is no fabricated carrier. +pub fn analyze_symbolic_cost_dimension( + d: &Dag, + workflow_root: NodeId, +) -> DimensionReport { + const DIMENSION_NAME: &str = "symbolic_cost"; + let scope = workflow_reachable_behavior_ids(d, workflow_root); + let mut witnesses = Vec::new(); + for behavior in d.nodes() { + if !scope.contains(&behavior.id()) { + continue; + } + let port = behavior_result_port(behavior); + match symbolic_cost_of(d, &port) { + SymbolicCostLookup::Miss => witnesses.push(Witness::Violates { + reason: "missing symbolic cost for behavior result port".into(), + at: behavior.clone(), + }), + SymbolicCostLookup::Hit(cost) => witnesses.push(Witness::Inhabits(cost)), + } + } + + let root = d.node(workflow_root); + let root_lookup = symbolic_cost_of(d, &behavior_result_port(root)); + + let witness_failure = witnesses + .iter() + .any(|w| matches!(w, Witness::Violates { .. })); + + if !witness_failure { + if let SymbolicCostLookup::Hit(composed) = root_lookup { + return DimensionReport::DimensionOk { + dimension_name: DIMENSION_NAME.to_string(), + composed, + witnesses, + }; + } + } + + let mut violations: Vec = witnesses + .iter() + .filter_map(|w| { + let Witness::Violates { reason, at } = w else { + return None; + }; + Some(Diagnostic::ParseError { + message: format!("symbolic_cost dimension: {reason}"), + span: behavior_span(at), + correction: Correction::deferred_for_diagnostic_class( + "SymbolicCostDimensionDiagnostic", + ), + }) + }) + .collect(); + + // Root `Miss` normally duplicates a `Witness::Violates` on the workflow root + // (same port as `behavior_result_port(root)`). Only synthesize a diagnostic + // here if the witness spine and root lookup ever disagree (fail-closed). + if matches!(root_lookup, SymbolicCostLookup::Miss) && violations.is_empty() { + violations.push(Diagnostic::ParseError { + message: "symbolic_cost dimension: missing symbolic cost for workflow root result port" + .into(), + span: behavior_span(root), + correction: Correction::deferred_for_diagnostic_class( + "SymbolicCostDimensionDiagnostic", + ), + }); + } + + DimensionReport::DimensionFail { + dimension_name: DIMENSION_NAME.to_string(), + violations, + witnesses, + } +} + +/// PR-E E7 symbolic-cost-only first executable slice: public +/// complexity-analysis entrypoint per +/// `docs/briefs/r3-evaluator-e7-symbolic-cost-only-follow-on-readiness.md`. +/// +/// **Single authority.** Thin wrapper that delegates to +/// [`analyze_symbolic_cost_dimension`] — the lens-spine path that walks +/// reachable behaviors from `workflow_root` via +/// `workflow_reachable_behavior_ids` and consumes +/// [`crate::lens_cost_symbolic::symbolic_cost_of`] for each behavior's +/// result port. The wrapper exists so the E7 public surface is named +/// the way the dispatch brief locks it (`analyze_complexity` / +/// `analyze_tenant_flow` / `analyze_ifc`) without introducing a +/// parallel analyzer. +/// +/// **Lens-spine, not body-evaluator-driven.** This entrypoint does +/// **not** depend on `eval_node` / `evaluate_body` at all — the +/// symbolic-cost lens (`lens_cost_symbolic_generated.rs`) walks the +/// program DAG structurally and produces `SymbolicCostLookup::Hit(cost)` +/// for every reachable behavior including `Loop`. Even now that +/// `eval_node` dispatches `Behavior::Loop` (E5 landed), this wrapper +/// remains the single-authority complexity entrypoint until +/// `analyze_with_evaluator` (the body-evaluator-driven form) lands; +/// at that point this lens-spine wrapper either dissolves or stays as +/// the not-evaluator-driven path for cost specifically. +/// +/// **Diagnostics.** `DimensionFail.violations` carries typed +/// [`Diagnostic`] entries; tests must assert by typed pattern match, +/// never by parsing `Witness::Violates.reason` strings. +pub fn analyze_complexity(dag: &Dag, workflow_root: NodeId) -> DimensionReport { + analyze_symbolic_cost_dimension(dag, workflow_root) +} + +#[cfg(test)] +mod analyze_complexity_tests { + use super::*; + use crate::dag::{literal_bits_int, Dag, TransformTarget}; + use crate::operators::{ArithmeticOp, OperatorKind}; + + fn span() -> SourceSpan { + SourceSpan::new("analyze_complexity_test", 0, 0) + } + + fn int_add_dag() -> (Dag, NodeId) { + let mut dag = Dag::new(); + let lhs = dag.push_value(literal_bits_int(1), span()); + let rhs = dag.push_value(literal_bits_int(2), span()); + let add_out = dag.push_transform( + TransformTarget::Operator(OperatorKind::Arithmetic(ArithmeticOp::Add)), + vec![lhs, rhs], + span(), + ); + let root = dag.push_bind("sum", add_out, vec![], span()); + (dag, root) + } + + // E7 §test 1 — single-authority: analyze_complexity must produce + // the same DimensionReport as the live analyze_symbolic_cost_dimension + // for the same (dag, workflow_root). Pins that the wrapper does not + // introduce a parallel analyzer. + #[test] + fn analyze_complexity_matches_analyze_symbolic_cost_dimension_for_known_workflow() { + let (dag, root) = int_add_dag(); + + let wrapped = analyze_complexity(&dag, root); + let direct = analyze_symbolic_cost_dimension(&dag, root); + + match (&wrapped, &direct) { + ( + DimensionReport::DimensionOk { + dimension_name: w_name, + composed: w_composed, + witnesses: w_witnesses, + }, + DimensionReport::DimensionOk { + dimension_name: d_name, + composed: d_composed, + witnesses: d_witnesses, + }, + ) => { + assert_eq!(w_name, d_name); + assert_eq!(w_composed, d_composed); + assert_eq!(w_witnesses.len(), d_witnesses.len()); + } + other => { + panic!("expected both branches DimensionOk with matching content, got {other:?}") + } + } + } + + // E7 §test 1 (returns_ok shape check) — DimensionOk arm carries the + // expected dimension_name and a SymbolicCost composed value. + #[test] + fn analyze_complexity_returns_ok_for_bounded_int_add_workflow() { + let (dag, root) = int_add_dag(); + + let report = analyze_complexity(&dag, root); + + let DimensionReport::DimensionOk { + dimension_name, + composed: _, + witnesses, + } = report + else { + panic!("expected DimensionOk for bounded int-add, got {report:?}"); + }; + assert_eq!(dimension_name, "symbolic_cost"); + assert!( + witnesses + .iter() + .all(|w| !matches!(w, Witness::Violates { .. })), + "no Violates witness expected on the happy path", + ); + } + + // E7 §test 2 — fail-closed on missing cost: program with a + // ghost-input transform produces DimensionFail with at least one + // typed Diagnostic::ParseError in violations. Asserted by typed + // pattern match on the Diagnostic enum, never by string parsing. + #[test] + fn analyze_complexity_fails_closed_with_typed_diagnostic_on_missing_cost() { + let mut dag = Dag::new(); + let int_shape = dag.int_shape().expect("bootstrap Int"); + let lhs = dag.push_value(literal_bits_int(1), span()); + let ghost_input = dag.alloc_port_with_shape(int_shape); + let bad_add = dag.push_transform( + TransformTarget::Operator(OperatorKind::Arithmetic(ArithmeticOp::Add)), + vec![lhs, ghost_input], + span(), + ); + let root = dag.push_bind("x", bad_add, vec![], span()); + + let report = analyze_complexity(&dag, root); + + let DimensionReport::DimensionFail { + dimension_name, + violations, + witnesses: _, + } = report + else { + panic!("expected DimensionFail when the cost lens misses a port, got {report:?}"); + }; + assert_eq!(dimension_name, "symbolic_cost"); + assert!( + !violations.is_empty(), + "DimensionFail must carry at least one typed diagnostic", + ); + assert!( + violations + .iter() + .all(|d| matches!(d, Diagnostic::ParseError { .. })), + "every violation must be a typed Diagnostic enum variant, got {violations:?}", + ); + } + + // E7 §test 3 — DimensionFail does not fabricate a `composed` + // carrier. Pattern-matching the `DimensionFail` arm is enough to + // assert this: the field is unreachable on that arm by + // construction. The wrapper preserves the partition. Reuses the + // ghost-input transform pattern from the existing + // `missing_symbolic_cost_surfaces_as_dimension_fail_with_violates_witnesses` + // test so the workflow is well-formed enough for the lens walk. + #[test] + fn analyze_complexity_fail_arm_has_no_composed_field() { + let mut dag = Dag::new(); + let int_shape = dag.int_shape().expect("bootstrap Int"); + let lhs = dag.push_value(literal_bits_int(1), span()); + let ghost_input = dag.alloc_port_with_shape(int_shape); + let bad_add = dag.push_transform( + TransformTarget::Operator(OperatorKind::Arithmetic(ArithmeticOp::Add)), + vec![lhs, ghost_input], + span(), + ); + let root = dag.push_bind("y", bad_add, vec![], span()); + + let report = analyze_complexity(&dag, root); + + match report { + DimensionReport::DimensionFail { .. } => { + // Exhaustive match on this arm: the only fields are + // dimension_name / violations / witnesses. There is no + // `composed` field to fabricate. Pattern coverage IS + // the assertion. + } + DimensionReport::DimensionOk { .. } => { + panic!("expected DimensionFail on a ghost-input workflow, not DimensionOk") + } + } + } + + // E7 §test 6 — typed-diagnostic discipline: every entry in + // DimensionFail.violations must be inspectable as a typed + // Diagnostic enum variant. No to_string / format / regex on the + // human-facing message field beyond non-empty checks. (Test #5 + // from the brief is a discipline rule enforced by reviewer + // convention, not a runtime assertion.) + #[test] + fn analyze_complexity_violation_entries_are_typed_diagnostic_enums() { + let mut dag = Dag::new(); + let int_shape = dag.int_shape().expect("bootstrap Int"); + let lhs = dag.push_value(literal_bits_int(1), span()); + let ghost_input = dag.alloc_port_with_shape(int_shape); + let bad_add = dag.push_transform( + TransformTarget::Operator(OperatorKind::Arithmetic(ArithmeticOp::Add)), + vec![lhs, ghost_input], + span(), + ); + let root = dag.push_bind("z", bad_add, vec![], span()); + + let report = analyze_complexity(&dag, root); + let DimensionReport::DimensionFail { violations, .. } = report else { + panic!("expected DimensionFail for ghost-port workflow"); + }; + + for diagnostic in &violations { + // Exhaustive enum match: every variant of Diagnostic is a + // structural type the test can pattern-match on without + // parsing message content. Non-`ParseError` variants are + // also acceptable typed inhabitants. + let typed = match diagnostic { + Diagnostic::ParseError { message, .. } => !message.is_empty(), + _ => true, + }; + assert!( + typed, + "Diagnostic must carry a non-empty human-facing message; got {diagnostic:?}", + ); + } + } +} + +#[cfg(test)] +mod fail_closed_tests { + use super::*; + use crate::dag::{literal_bits_int, Dag, TransformTarget}; + use crate::operators::{ArithmeticOp, OperatorKind}; + + #[test] + fn missing_symbolic_cost_surfaces_as_dimension_fail_with_violates_witnesses() { + let mut dag = Dag::new(); + let span = SourceSpan::new("dimension_fail_closed_test", 0, 0); + let int_shape = dag.int_shape().expect("bootstrap Int"); + let lhs = dag.push_value(literal_bits_int(1), span.clone()); + let ghost_input = dag.alloc_port_with_shape(int_shape); + let bad_add = dag.push_transform( + TransformTarget::Operator(OperatorKind::Arithmetic(ArithmeticOp::Add)), + vec![lhs, ghost_input], + span.clone(), + ); + let bind_id = dag.push_bind("x", bad_add, vec![], span); + + let report = analyze_symbolic_cost_dimension(&dag, bind_id); + let DimensionReport::DimensionFail { + violations, + witnesses, + .. + } = report + else { + panic!("expected DimensionFail when cost lens misses an input port, got {report:?}"); + }; + + assert!( + violations + .iter() + .any(|v| matches!(v, Diagnostic::ParseError { .. })), + "expected ParseError diagnostics from dimension proof failure, got {violations:?}" + ); + assert!( + witnesses + .iter() + .any(|w| matches!(w, Witness::Violates { .. })), + "expected at least one Violates witness, got {witnesses:?}" + ); + assert!( + witnesses + .iter() + .all(|w| !matches!(w, Witness::Inhabits(SymbolicCost::UnknownCost { .. }))), + "dimension witnesses must not fabricate UnknownCost carriers" + ); + } + + #[test] + fn transform_workflow_root_still_backward_reaches_operand_ports() { + let mut dag = Dag::new(); + let span = SourceSpan::new("transform_root_reach_test", 0, 0); + let lhs = dag.push_value(literal_bits_int(1), span.clone()); + let rhs = dag.push_value(literal_bits_int(2), span.clone()); + let add_out = dag.push_transform( + TransformTarget::Operator(OperatorKind::Arithmetic(ArithmeticOp::Add)), + vec![lhs, rhs], + span, + ); + let transform_root = dag + .port(add_out) + .produced_by + .expect("transform output port wired to its node"); + + let report = analyze_symbolic_cost_dimension(&dag, transform_root); + let DimensionReport::DimensionOk { witnesses, .. } = report else { + panic!("expected DimensionOk for well-wired Int add, got {report:?}"); + }; + assert!( + witnesses.len() >= 3, + "expected transform + two literal witnesses, got {}", + witnesses.len() + ); + } +} diff --git a/tools/pb0_cycle4_emit_templates/infer.rs.in b/tools/pb0_cycle4_emit_templates/infer.rs.in new file mode 100644 index 00000000000..8e62b4bc523 --- /dev/null +++ b/tools/pb0_cycle4_emit_templates/infer.rs.in @@ -0,0 +1,7262 @@ +// Type inference: fills Port.state by propagating types forward through +// the DAG. Dispatches on TypeConnective (M1_DESIGN.md §4) instead of the +// old name-based function signature lookup. +// +// Transform.target is a DeclarationId. Inference reads the target's +// Declaration and: +// - Arrow { inputs, output, body }: direct signature from the input and +// output declarations, converted to port-level TypeShape via the +// `declaration_to_type_shape` bridge. +// - Atom(Identifier { name, resolved }): follow the resolved link if set, +// otherwise look up the name in the Dag's declaration table +// (§8.9 inhabitance walk, reduced at M1(2.5) to direct primitive lookup +// pending the full algebra bootstrap in Phase 5). +// - Anything else: not callable. +// +// Fail-closed (invariant C-8): every detectable problem goes through +// Dag::mark_unresolved. After inference, a post-sweep drives any remaining +// Uninferred ports to Unresolved with a generic diagnostic so the +// biconditional +// state == Unresolved iff diagnostics.contains(port_id) +// and the post-invariant +// state != Uninferred after infer completes +// both hold. +// +// **M1 extension — inference mutates `Dag.declarations`:** operator / algebra +// resolution may `push_declaration` for anonymous `Instantiation` shapes (e.g. +// `Result` for totalizing `div`); `find_equivalent_anonymous_instantiation` +// deduplicates so fixpoint growth stays bounded. `decide` takes `&mut Dag` and clones +// each `Behavior` before matching so borrows stay sound — inference is not read-only +// over the declaration table. + +use std::collections::HashSet; + +use crate::dag::{ + substrate_div_error_type_decl_suppressed_for_emit, + substrate_result_type_decl_suppressed_for_emit, ArithmeticOp, ArrowBody, AtomPayload, Behavior, + BindNode, BindNodeId, CardinalityBound, Dag, Declaration, DeclarationId, Field, LiteralBits, + Lookup, NominalOpacity, PhantomParameter, PortId, PortState, TemplateArgument, TransformNode, + TransformTarget, TypeConnective, +}; +use crate::diagnostics::{ + declaration_display_name, example_source_for_decl, witness_correction_for_decl, Correction, + Diagnostic, SourceSpan, +}; +use crate::infer_helpers::{ + behavior_output_port, generated_template_arguments_match, normalize_instantiation_arguments, + payload_binding_span as generated_payload_binding_span, + push_template_argument_binding as generated_push_template_argument_binding, + resolve_template_argument_value as generated_resolve_template_argument_value, + template_argument_value as generated_template_argument_value, NormalizedInstantiationArgs, + TemplateArgumentBinding, TemplateArgumentsMatch, +}; +use std::str::FromStr; + +use num_bigint::BigInt; + +use crate::int_literal_ranges::{ + int_literal_fits_expected_type, integer_range_for_decl, literal_bigint_at, + magnitude_out_of_range_for_interval, IntegerRangeLookup, +}; +use crate::lower::{clone_predicate_body, outer_predicate_slots}; +use crate::operators::{LogicalOp, OperatorKind}; +use crate::types::TypeShape; + +/// Outcome of [`try_reconcile_int_literal_decision_set`]. +enum IntLiteralSetReconciliation { + /// Reconciled without changing the port (narrow annotation already wins over default `Int`). + Keep, + /// Applied `set_port_type` to narrow a default-`Int` literal port to `ty`. + SetNarrow, + /// `mark_unresolved` with a magnitude or other diagnostic. + Marked, +} + +/// Reconcile `Decision::Set` for ports backed by an integer literal when the proposed +/// and existing [`TypeShape`] would otherwise `TypeMismatch`: (a) `let`/`data` +/// pre-seed vs default `Int`, (b) call-sites that propose a callee's narrow +/// type over a default-`Int` argument. +fn try_reconcile_int_literal_decision_set( + dag: &mut Dag, + port: PortId, + existing: TypeShape, + ty: TypeShape, +) -> Option { + let default_int = dag.int_shape()?; + let literal = literal_bigint_at(dag, port)?; + if type_shapes_equivalent(dag, &ty, &default_int) { + match int_literal_fits_expected_type(dag, &literal, existing.declaration) { + Ok(Some(true)) => Some(IntLiteralSetReconciliation::Keep), + Ok(Some(false)) => { + if let IntegerRangeLookup::Found(range) = + integer_range_for_decl(dag, existing.declaration) + { + let span = node_span_for_port(dag, port).unwrap_or_else(synthetic_span); + dag.mark_unresolved( + port, + magnitude_out_of_range_for_interval(&literal, existing, range, span), + ); + Some(IntLiteralSetReconciliation::Marked) + } else { + None + } + } + Ok(None) => None, + Err(diag) => { + if !matches!(dag.port(port).state(), PortState::Unresolved) { + dag.mark_unresolved(port, diag); + } + Some(IntLiteralSetReconciliation::Marked) + } + } + } else if type_shapes_equivalent(dag, &existing, &default_int) { + match int_literal_fits_expected_type(dag, &literal, ty.declaration) { + Ok(Some(true)) => { + dag.set_port_type(port, ty); + Some(IntLiteralSetReconciliation::SetNarrow) + } + Ok(Some(false)) => { + if let IntegerRangeLookup::Found(range) = + integer_range_for_decl(dag, ty.declaration) + { + let span = node_span_for_port(dag, port).unwrap_or_else(synthetic_span); + dag.mark_unresolved( + port, + magnitude_out_of_range_for_interval(&literal, ty, range, span), + ); + Some(IntLiteralSetReconciliation::Marked) + } else { + None + } + } + Ok(None) => None, + Err(diag) => { + if !matches!(dag.port(port).state(), PortState::Unresolved) { + dag.mark_unresolved(port, diag); + } + Some(IntLiteralSetReconciliation::Marked) + } + } + } else { + None + } +} + +pub fn infer(dag: &mut Dag) { + // Fixpoint loop. Runs decide for every node, then pattern + // resolution + exhaustiveness + uniqueness for every Branch. + // Pattern resolution is folded into the loop (not run after it) + // so a Branch that gets flipped to Unresolved by + // non-exhaustive/duplicate-arm checks propagates to downstream + // consumers: the next iteration's decide pass sees the upstream + // Unresolved and cascades Decision::Fail through Transform / + // Branch / Loop / Bind consumers. Running pattern resolution + // after the main loop would leave downstream types stale and + // violate FAIL-CLOSED. + loop { + ensure_all_optional_match_disjs(dag); + let mut changed = false; + let node_count = dag.nodes().len(); + for i in 0..node_count { + match decide(dag, i) { + Decision::Set(port, ty) => { + if matches!(dag.port(port).state(), PortState::Unresolved) { + continue; + } + match dag.port(port).state().clone() { + PortState::Uninferred => { + dag.set_port_type(port, ty); + changed = true; + } + PortState::Resolved(existing) + if type_shapes_equivalent(dag, &existing, &ty) => {} + PortState::Resolved(existing) => { + match int_literal_magnitude_narrow_merge(dag, port, &existing, &ty) { + IntLiteralNarrowMerge::Merge => { + dag.set_port_type(port, ty); + changed = true; + continue; + } + IntLiteralNarrowMerge::Reject(diag) => { + dag.mark_unresolved(port, *diag); + changed = true; + continue; + } + IntLiteralNarrowMerge::NotApplicable => {} + } + if is_retryable_generic_decl(dag, existing.declaration) + || is_retryable_generic_decl(dag, ty.declaration) + { + dag.set_port_type(port, ty); + changed = true; + continue; + } + if let Some(outcome) = + try_reconcile_int_literal_decision_set(dag, port, existing, ty) + { + match outcome { + IntLiteralSetReconciliation::Keep => continue, + IntLiteralSetReconciliation::SetNarrow + | IntLiteralSetReconciliation::Marked => { + changed = true; + continue; + } + } + } + let span = node_span_for_port(dag, port).unwrap_or_else(synthetic_span); + let correction = witness_correction_for_decl( + dag, + existing.declaration, + span.clone(), + format!( + "replace this expression with a `{}` value", + declaration_display_name(dag, existing.declaration) + ), + ) + .unwrap_or_else(|| { + Correction::deferred_for_diagnostic_class("InferenceDiagnostic") + }); + let diag = Diagnostic::TypeMismatch { + expected: existing, + actual: ty, + span, + correction, + }; + dag.mark_unresolved(port, diag); + changed = true; + } + PortState::Unresolved => unreachable!("guarded above"), + } + } + Decision::Fail(port, diag) => { + if !matches!(dag.port(port).state(), PortState::Unresolved) { + dag.mark_unresolved(port, *diag); + changed = true; + } + } + Decision::Retry => {} + } + } + if resolve_branch_patterns(dag) { + changed = true; + } + if resolve_branch_payload_bindings(dag) { + changed = true; + } + if resolve_field_project_targets(dag) { + changed = true; + } + if resolve_callable_targets(dag) { + changed = true; + } + if materialize_callable_signature_instantiations(dag) { + changed = true; + } + if resolve_lambda_parameter_types(dag) { + changed = true; + } + if validate_user_defined_function_signatures(dag) { + changed = true; + } + if !changed { + break; + } + } + + // Post-sweep: any port still Uninferred means decide() missed a case. + let uninferred_ports: Vec = dag + .all_ports() + .filter(|p| matches!(p.state(), PortState::Uninferred)) + .map(|p| p.id()) + .collect(); + for port in uninferred_ports { + let span = node_span_for_port(dag, port).unwrap_or_else(synthetic_span); + dag.mark_unresolved( + port, + Diagnostic::ResolveError { + name: "(inference did not resolve this port)".to_string(), + span, + correction: Correction::deferred_for_diagnostic_class("InferenceDiagnostic"), + }, + ); + } + + crate::enforced_lens_application::check_enforced_lens_applications(dag); +} + +/// Walk a declaration through `Instantiation` / `ResolvedIdentifier` +/// edges until it reaches a declaration whose connective is +/// `Disj`. Returns the `DeclarationId` of that Disj declaration — +/// or `None` if no Disj is reachable within `WALK_DEPTH_LIMIT` +/// steps, or the chain terminates at some other connective. +/// +/// Used by both the `decide_transform` Branch gate and the +/// post-infer `resolve_branch_patterns` pass so that aliased or +/// instantiated sum types (`type Hue = Color` where +/// `Color = Red | Green | Blue`) resolve to the same underlying +/// `Disj` fact whether their immediate connective is the Disj +/// itself, an `Instantiation` pointing at it, or an +/// `Atom(ResolvedIdentifier(...))` chain of aliases. The +/// alternative — reading `decl.connective` directly — would +/// reject aliases even though the underlying sum fact survives +/// in the declaration graph, violating Facts Flow Forward. +fn walk_to_disj_decl(dag: &Dag, start: DeclarationId) -> Option { + let mut current = start; + for _ in 0..WALK_DEPTH_LIMIT { + let decl = dag.declaration(current); + match &decl.connective { + TypeConnective::Disj { .. } => return Some(current), + TypeConnective::Cardinality(p) + if p.bound() == crate::dag::CardinalityBound::AtMostOne => + { + return existing_optional_match_disj_decl(dag, current); + } + TypeConnective::Instantiation { template, .. } => { + current = *template; + } + TypeConnective::Atom(AtomPayload::ResolvedByStructure(next)) + | TypeConnective::Atom(AtomPayload::ResolvedByName(next)) => { + current = *next; + } + _ => return None, + } + } + None +} + +fn existing_optional_match_disj_decl( + dag: &Dag, + cardinality_decl_id: DeclarationId, +) -> Option { + dag.optional_match_disj(cardinality_decl_id) +} + +fn walk_to_optional_cardinality_decl(dag: &Dag, start: DeclarationId) -> Option { + let mut current = start; + for _ in 0..WALK_DEPTH_LIMIT { + match &dag.declaration(current).connective { + TypeConnective::Cardinality(p) + if p.bound() == crate::dag::CardinalityBound::AtMostOne => + { + return Some(current); + } + TypeConnective::Instantiation { template, .. } => current = *template, + TypeConnective::Atom(AtomPayload::ResolvedByStructure(next)) + | TypeConnective::Atom(AtomPayload::ResolvedByName(next)) => current = *next, + _ => return None, + } + } + None +} + +pub(crate) fn ensure_optional_match_disj( + dag: &mut Dag, + cardinality_decl_id: DeclarationId, +) -> Option { + if let Some(existing) = existing_optional_match_disj_decl(dag, cardinality_decl_id) { + return Some(existing); + } + let (element, span) = match dag.declaration(cardinality_decl_id).connective.clone() { + TypeConnective::Cardinality(p) if p.bound() == crate::dag::CardinalityBound::AtMostOne => ( + p.element(), + dag.declaration(cardinality_decl_id).span.clone(), + ), + _ => return None, + }; + + let some_payload_id = dag.alloc_declaration_id(); + dag.push_declaration(Declaration { + id: some_payload_id, + name: None, + connective: TypeConnective::Conj { + children: vec![Field { + label: "_0".to_string(), + ty: element, + }], + }, + type_params: Vec::new(), + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: None, + span: span.clone(), + }); + + let none_payload_id = dag.alloc_declaration_id(); + dag.push_declaration(Declaration { + id: none_payload_id, + name: None, + connective: TypeConnective::Conj { + children: Vec::new(), + }, + type_params: Vec::new(), + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: None, + span: span.clone(), + }); + + let disj_id = dag.alloc_declaration_id(); + dag.push_declaration(Declaration { + id: disj_id, + name: None, + connective: TypeConnective::Disj { + variants: vec![ + Field { + label: "Some".to_string(), + ty: some_payload_id, + }, + Field { + label: "None".to_string(), + ty: none_payload_id, + }, + ], + }, + type_params: Vec::new(), + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: None, + span, + }); + dag.set_optional_match_disj(cardinality_decl_id, disj_id); + + Some(disj_id) +} + +/// Materialize `Some`/`None` match sums for every `Cardinality(_, AtMostOne)` row so +/// downstream structural consumers (substrate reflection, lenses) can use +/// `Dag::optional_match_disj` without requiring an optional scrutinee on a Branch. +pub(crate) fn ensure_all_optional_match_disjs(dag: &mut Dag) { + let decl_ids: Vec = dag.declarations().iter().map(|d| d.id).collect(); + for decl_id in decl_ids { + if matches!( + &dag.declaration(decl_id).connective, + TypeConnective::Cardinality(p) if p.bound() == CardinalityBound::AtMostOne + ) && existing_optional_match_disj_decl(dag, decl_id).is_none() + { + ensure_optional_match_disj(dag, decl_id).expect( + "AtMostOne cardinality without optional_match_disj should materialize a Some/None disj", + ); + } + } +} + +/// For every Branch node, resolve each Path's `BranchPattern` by +/// matching the arm's variant name against the scrutinee's Disj +/// children and verify exhaustiveness + uniqueness. Mutates the +/// Path in place for successful rewrites, marks the Branch's +/// output port Unresolved (via `Dag::mark_unresolved`) for +/// failures. Skips Branches whose input port is not yet Resolved — +/// those will be revisited on the next fixpoint iteration. +/// +/// Aliases / instantiations of sum types normalize through +/// `walk_to_disj_decl` before variant resolution, so `type Hue = +/// Color` and `Color = Red | Green | Blue` both resolve arms +/// against `Color`'s variants. +/// +/// Returns `true` if any state (Path.pattern rewrite or Branch +/// output port transition) changed, so the calling fixpoint loop +/// knows to keep iterating. Pattern-resolution changes and +/// coverage-check failures both propagate downstream through the +/// normal `decide_transform` cascade in subsequent iterations. +fn resolve_branch_patterns(dag: &mut Dag) -> bool { + let mut changed = false; + let optional_scrutinees: HashSet = dag + .nodes() + .iter() + .filter_map(|node| { + let Behavior::Branch(branch) = node else { + return None; + }; + let PortState::Resolved(ty) = dag.port(branch.input).state() else { + return None; + }; + walk_to_optional_cardinality_decl(dag, ty.declaration) + }) + .collect(); + for decl_id in optional_scrutinees { + if existing_optional_match_disj_decl(dag, decl_id).is_none() + && ensure_optional_match_disj(dag, decl_id).is_some() + { + changed = true; + } + } + // Collect the rewrites first (immutable borrow of nodes + ports + + // declarations), then apply them (mutable borrow of nodes). This + // two-phase pattern avoids borrow conflicts while still reading + // declaration bodies. + struct Rewrite { + node_index: usize, + path_index: usize, + result: Result, + output_port: PortId, + } + let mut rewrites: Vec = Vec::new(); + // Coverage check: for each Branch, after resolving all paths, + // verify the resolved set equals the scrutinee's Disj variant set + // exactly (every variant covered by exactly one arm). Missing or + // duplicated arms are fail-closed diagnostics on the Branch's + // output port. + struct CoverageCheck { + output_port: PortId, + span: SourceSpan, + expected: Vec<(String, DeclarationId)>, + arm_outputs: Vec, + // Parallel vec of (resolved_decl_id, arm_name_for_diagnostic) + // collected in path order. Paths whose resolution failed are + // not included — their error already fires, so coverage is + // reported only on the subset that resolved. + resolved_arms: Vec<(DeclarationId, String)>, + } + let mut coverage_checks: Vec = Vec::new(); + for (node_index, node) in dag.nodes().iter().enumerate() { + let Behavior::Branch(b) = node else { + continue; + }; + let scrutinee_ty = match dag.port(b.input).state() { + PortState::Resolved(ty) => *ty, + PortState::Unresolved | PortState::Uninferred => continue, + }; + // Walk the scrutinee through alias / instantiation edges to + // the underlying Disj. `type Hue = Color` and + // `Color = Red | Green | Blue` both resolve arms against + // `Color`'s variants via this walk. + let Some(disj_decl_id) = walk_to_disj_decl(dag, scrutinee_ty.declaration).or_else(|| { + walk_to_optional_cardinality_decl(dag, scrutinee_ty.declaration) + .and_then(|cardinality| existing_optional_match_disj_decl(dag, cardinality)) + }) else { + // Scrutinee doesn't resolve to a Disj — the main infer + // pass caught this as a TypeMismatch already. Skip. + continue; + }; + let disj_variants: Vec<(String, DeclarationId)> = + match &dag.declaration(disj_decl_id).connective { + TypeConnective::Disj { variants } => { + variants.iter().map(|f| (f.label.clone(), f.ty)).collect() + } + _ => unreachable!("walk_to_disj_decl returned a non-Disj declaration"), + }; + let mut resolved_arms: Vec<(DeclarationId, String)> = Vec::new(); + for (path_index, path) in b.paths.iter().enumerate() { + let result = match &path.pattern { + crate::dag::BranchPattern::ResolvedVariant(id) => { + // Already resolved (e.g., if/else paths on a + // second infer pass). Record for coverage check + // and skip the rewrite. + let arm_name = disj_variants + .iter() + .find(|(_, variant_id)| variant_id == id) + .map(|(label, _)| label.clone()) + .unwrap_or_else(|| format!("declaration#{}", id.raw())); + resolved_arms.push((*id, arm_name)); + continue; + } + crate::dag::BranchPattern::UnresolvedVariant { name, span } => { + match disj_variants.iter().find(|(label, _)| label == name) { + Some((_, ty)) => { + resolved_arms.push((*ty, name.clone())); + Ok(*ty) + } + None => Err(Diagnostic::ResolveError { + name: format!( + "variant `{name}` is not a constructor of this match's scrutinee type" + ), + span: span.clone(), + correction: Correction::deferred_for_diagnostic_class("InferenceDiagnostic"), + }), + } + } + }; + rewrites.push(Rewrite { + node_index, + path_index, + result, + output_port: b.output, + }); + } + coverage_checks.push(CoverageCheck { + output_port: b.output, + span: b.span.clone(), + expected: disj_variants, + arm_outputs: b.paths.iter().map(|path| path.output).collect(), + resolved_arms, + }); + } + for rewrite in rewrites { + match rewrite.result { + Ok(variant_id) => { + if let Behavior::Branch(b) = &mut dag.nodes_mut()[rewrite.node_index] { + b.paths[rewrite.path_index].pattern = + crate::dag::BranchPattern::ResolvedVariant(variant_id); + changed = true; + } + } + Err(diag) => { + if !matches!(dag.port(rewrite.output_port).state(), PortState::Unresolved) { + dag.mark_unresolved(rewrite.output_port, diag); + changed = true; + } + } + } + } + // Coverage pass: for each Branch whose paths resolved (fully or + // partially), verify exhaustiveness (every variant covered) and + // uniqueness (no variant duplicated). Skip if the Branch's output + // already has a diagnostic from a pattern resolution failure — + // coverage is meaningless on a partially-resolved arm set. + for check in coverage_checks { + if matches!(dag.port(check.output_port).state(), PortState::Unresolved) { + continue; + } + // Uniqueness: each resolved DeclarationId appears at most once. + let mut seen: HashSet = HashSet::new(); + let mut duplicate: Option = None; + for (id, name) in &check.resolved_arms { + if !seen.insert(*id) { + duplicate = Some(name.clone()); + break; + } + } + if let Some(name) = duplicate { + dag.mark_unresolved( + check.output_port, + Diagnostic::ResolveError { + name: format!( + "duplicate match arm for variant `{name}` — each variant of a sum type must match exactly once" + ), + span: check.span.clone(), + correction: Correction::deferred_for_diagnostic_class("InferenceDiagnostic"), + }, + ); + changed = true; + continue; + } + // Exhaustiveness: every expected variant must be in the + // resolved set. Surface the first missing variant's name as + // the diagnostic label. + let missing: Vec<&str> = check + .expected + .iter() + .filter(|(_, id)| !seen.contains(id)) + .map(|(label, _)| label.as_str()) + .collect(); + if !missing.is_empty() { + let missing_list = missing.join(", "); + let arm_prefix = if check.resolved_arms.is_empty() { + "" + } else { + ", " + }; + let body = branch_output_example_source(dag, check.output_port, &check.arm_outputs); + let insert_at = check.span.byte_end.saturating_sub(1); + let correction_span = SourceSpan::new(check.span.file.clone(), insert_at, insert_at); + let arms = missing + .iter() + .map(|variant| format!("{variant} => {body}")) + .collect::>() + .join(", "); + dag.mark_unresolved( + check.output_port, + Diagnostic::ResolveError { + name: format!( + "non-exhaustive match: missing arm(s) for variant(s) `{missing_list}` — every constructor of the scrutinee's sum type must be covered" + ), + span: check.span, + correction: Correction::live( + format!("add missing arm(s) for `{missing_list}`"), + correction_span, + format!("{arm_prefix}{arms}"), + ), + }, + ); + changed = true; + } + } + changed +} + +fn branch_output_example_source(dag: &Dag, output_port: PortId, arm_outputs: &[PortId]) -> String { + std::iter::once(output_port) + .chain(arm_outputs.iter().copied()) + .find_map(|port| match dag.port(port).state() { + PortState::Resolved(output_ty) => example_source_for_decl(dag, output_ty.declaration), + PortState::Uninferred | PortState::Unresolved => None, + }) + .unwrap_or_else(|| "()".to_string()) +} + +fn branch_payload_binding_span(path: &crate::dag::Path, branch_span: &SourceSpan) -> SourceSpan { + let extent = generated_payload_binding_span(path, branch_span); + let file = match &path.pattern { + crate::dag::BranchPattern::UnresolvedVariant { span, .. } => span.file.clone(), + crate::dag::BranchPattern::ResolvedVariant(_) => branch_span.file.clone(), + }; + SourceSpan::new(file, extent.byte_start, extent.byte_end) +} + +fn resolve_branch_payload_bindings(dag: &mut Dag) -> bool { + struct BindingRewrite { + port: PortId, + span: SourceSpan, + result: Result, + } + + let mut rewrites: Vec = Vec::new(); + + for node in dag.nodes() { + let Behavior::Branch(b) = node else { + continue; + }; + + match dag.port(b.input).state() { + PortState::Uninferred => continue, + PortState::Unresolved => { + for path in &b.paths { + let Some(binding) = &path.binding else { + continue; + }; + rewrites.push(BindingRewrite { + port: binding.payload_port, + span: branch_payload_binding_span(path, &b.span), + result: Err(Diagnostic::ResolveError { + name: "(upstream failure in match payload binding)".to_string(), + span: branch_payload_binding_span(path, &b.span), + correction: Correction::deferred_for_diagnostic_class( + "InferenceDiagnostic", + ), + }), + }); + } + } + PortState::Resolved(scrutinee_ty) => { + let mut subst = SubstStack::new(); + let disj_decl_id = + walk_to_disj_decl_with_subst(dag, scrutinee_ty.declaration, &mut subst); + if disj_decl_id.is_none() + && is_retryable_generic_decl(dag, scrutinee_ty.declaration) + { + continue; + } + let variants = disj_decl_id.map(|id| match &dag.declaration(id).connective { + TypeConnective::Disj { variants } => variants + .iter() + .map(|field| (field.label.clone(), field.ty)) + .collect::>(), + _ => { + unreachable!("walk_to_disj_decl_with_subst returned a non-Disj declaration") + } + }); + + for path in &b.paths { + let Some(binding) = &path.binding else { + continue; + }; + let span = branch_payload_binding_span(path, &b.span); + let result = match &variants { + None => Err(Diagnostic::ResolveError { + name: format!( + "cannot bind payload `{}` because the match scrutinee does not walk to a Disj type", + binding.binding_name + ), + span: span.clone(), + correction: Correction::deferred_for_diagnostic_class("InferenceDiagnostic"), + }), + Some(variants) => { + let variant = match &path.pattern { + crate::dag::BranchPattern::ResolvedVariant(id) => { + variants.iter().find(|(_, ty)| *ty == *id) + } + crate::dag::BranchPattern::UnresolvedVariant { name, .. } => { + variants.iter().find(|(label, _)| label == name) + } + }; + match variant { + Some((variant_name, variant_id)) => resolve_payload_binding_type( + dag, + *variant_id, + &subst, + variant_name, + &binding.binding_name, + &span, + ), + None => { + let arm_name = match &path.pattern { + crate::dag::BranchPattern::ResolvedVariant(id) => variants + .iter() + .find(|(_, ty)| *ty == *id) + .map(|(label, _)| label.clone()) + .unwrap_or_else(|| format!("declaration#{}", id.raw())), + crate::dag::BranchPattern::UnresolvedVariant { name, .. } => { + name.clone() + } + }; + Err(Diagnostic::ResolveError { + name: format!( + "variant `{arm_name}` is not a constructor of this match's scrutinee type" + ), + span: span.clone(), + correction: Correction::deferred_for_diagnostic_class("InferenceDiagnostic"), + }) + } + } + } + }; + rewrites.push(BindingRewrite { + port: binding.payload_port, + span, + result, + }); + } + } + } + } + + let mut changed = false; + for rewrite in rewrites { + match rewrite.result { + Ok(resolution) => { + let ty = match resolution { + PayloadBindingResolution::Direct(ty) => ty, + PayloadBindingResolution::SpecializedRecord { + variant_decl_id, + subst, + } => materialize_specialized_payload_record(dag, variant_decl_id, &subst), + }; + match dag.port(rewrite.port).state().clone() { + PortState::Unresolved => {} + PortState::Uninferred => { + dag.set_port_type(rewrite.port, ty); + changed = true; + } + PortState::Resolved(existing) + if type_shapes_equivalent(dag, &existing, &ty) => {} + PortState::Resolved(existing) => { + dag.mark_unresolved( + rewrite.port, + Diagnostic::TypeMismatch { + expected: existing, + actual: ty, + span: rewrite.span, + correction: Correction::deferred_for_diagnostic_class( + "InferenceDiagnostic", + ), + }, + ); + changed = true; + } + } + } + Err(diag) => { + if !matches!(dag.port(rewrite.port).state(), PortState::Unresolved) { + dag.mark_unresolved(rewrite.port, diag); + changed = true; + } + } + } + } + + changed +} + +/// T-Modeling int-lit / PR #806 consumer: allow `i64` default-typed int +/// literal output ports to refine to a range-backed fixed-width `IntegerPrimitive` +/// when the magnitude fits, without treating it as a conflicting resolution. +#[derive(Debug)] +enum IntLiteralNarrowMerge { + /// Narrow to the fixed-width type — caller should `set_port_type` to `to`. + Merge, + /// Not a default-`Int` literal → fixed-width call-site narrow case. + NotApplicable, + /// Preserve typed diagnostics (e.g. [`Diagnostic::MagnitudeOutOfRange`]) like the + /// annotated-`let` value-node path, not a silent fallback to `TypeMismatch`. + Reject(Box), +} + +impl IntLiteralNarrowMerge { + fn reject(diagnostic: Diagnostic) -> Self { + Self::Reject(Box::new(diagnostic)) + } +} + +fn int_literal_magnitude_narrow_merge( + dag: &Dag, + port: PortId, + from: &TypeShape, + to: &TypeShape, +) -> IntLiteralNarrowMerge { + let Some(lit) = literal_bigint_at(dag, port) else { + return IntLiteralNarrowMerge::NotApplicable; + }; + let Some(int_shape) = dag.int_shape() else { + return IntLiteralNarrowMerge::NotApplicable; + }; + if !type_shapes_equivalent(dag, from, &int_shape) { + return IntLiteralNarrowMerge::NotApplicable; + } + let span = node_span_for_port(dag, port).unwrap_or_else(synthetic_span); + match int_literal_fits_expected_type(dag, &lit, to.declaration) { + Ok(Some(true)) => IntLiteralNarrowMerge::Merge, + Ok(Some(false)) => match integer_range_for_decl(dag, to.declaration) { + IntegerRangeLookup::Found(bound) => IntLiteralNarrowMerge::reject( + magnitude_out_of_range_for_interval(&lit, *to, bound, span), + ), + IntegerRangeLookup::Invalid(diag) => IntLiteralNarrowMerge::reject(diag), + IntegerRangeLookup::Missing => { + IntLiteralNarrowMerge::reject(Diagnostic::ResolveError { + name: "(internal: integer literal out of range but no range fact)".to_string(), + span, + correction: Correction::deferred_for_diagnostic_class("InferenceDiagnostic"), + }) + } + }, + Err(diag) => IntLiteralNarrowMerge::reject(diag), + Ok(None) => IntLiteralNarrowMerge::NotApplicable, + } +} + +enum Decision { + Set(PortId, TypeShape), + Fail(PortId, Box), + Retry, +} + +impl Decision { + fn fail(port: PortId, diagnostic: Diagnostic) -> Self { + Self::Fail(port, Box::new(diagnostic)) + } +} + +fn decide(dag: &mut Dag, index: usize) -> Decision { + // Clone before matching so `decide_transform` can take `&mut Dag` without + // holding an immutable borrow of `dag.nodes()` across the call. + let behavior = dag.nodes()[index].clone(); + match &behavior { + Behavior::Value(v) => { + // `let x: UInt8 = 5` seeds the value port to `UInt8` after lowering; + // the Int literal Value node would otherwise re-stamp `Int64` here and + // clobber host narrowing. If the port is already a non-`Int` type, only + // defer (Retry) when range facts prove the literal fits that type. + // Otherwise fail closed with `MagnitudeOutOfRange` (same contract as + // call-site narrowing) or fall through so normal mismatch handling + // applies when there is no range-backed check. + if let LiteralBits::Int(lit_str) = &v.data { + let Some(literal) = BigInt::from_str(lit_str.as_str()).ok() else { + return Decision::fail( + v.output, + Diagnostic::ResolveError { + name: "internal: malformed decimal integer literal".to_string(), + span: v.span.clone(), + correction: Correction::deferred_for_diagnostic_class( + "InferenceDiagnostic", + ), + }, + ); + }; + if let PortState::Resolved(existing) = dag.port(v.output).state() { + if let Some(int_sh) = dag.int_shape() { + if !type_shapes_equivalent(dag, existing, &int_sh) { + match int_literal_fits_expected_type( + dag, + &literal, + existing.declaration, + ) { + Ok(Some(true)) => return Decision::Retry, + Ok(Some(false)) => { + match integer_range_for_decl(dag, existing.declaration) { + IntegerRangeLookup::Found(range) => { + return Decision::fail( + v.output, + magnitude_out_of_range_for_interval( + &literal, + *existing, + range, + v.span.clone(), + ), + ); + } + IntegerRangeLookup::Invalid(diag) => { + return Decision::fail(v.output, diag); + } + IntegerRangeLookup::Missing => { + return Decision::fail( + v.output, + Diagnostic::ResolveError { + name: "(internal: integer literal out of range but no range fact)" + .to_string(), + span: v.span.clone(), + correction: Correction::deferred_for_diagnostic_class("InferenceDiagnostic"), + }, + ); + } + } + } + Err(diag) => return Decision::fail(v.output, diag), + Ok(None) => {} + } + } + } + } + } + let shape_and_name = match &v.data { + // Unconstrained integer literals keep the existing explicit + // default to Int64. Expected-type narrowing happens at + // reconciliation sites below, against range facts. + LiteralBits::Int(_) => (dag.int_shape(), "Int"), + LiteralBits::Bool(_) => (dag.bool_shape(), "Bool"), + LiteralBits::String(_) => (dag.string_shape(), "String"), + }; + let Some(ty) = shape_and_name.0 else { + return Decision::fail( + v.output, + Diagnostic::ResolveError { + name: format!( + "primitive `{}` missing from declaration table — bootstrap failed", + shape_and_name.1 + ), + span: v.span.clone(), + correction: Correction::deferred_for_diagnostic_class( + "InferenceDiagnostic", + ), + }, + ); + }; + Decision::Set(v.output, ty) + } + Behavior::Transform(t) => decide_transform(dag, t), + Behavior::Branch(b) => { + // Branch dispatches on a sum type. Its input must + // walk through alias/instantiation edges to a + // declaration whose connective is + // `TypeConnective::Disj { .. }`. Bool and Classical are + // Disj declarations (True | False), so `if` lowers here + // naturally. User-defined sums like + // `type Sign = Plus | Minus` also pass. Aliased sums + // (`type Hue = Color` where Color is a sum) pass + // via `walk_to_disj_decl`. String, Int, Float, etc. + // fail this check — the previous M0 "must be Bool" + // rule was the degenerate case of this broader rule. + match dag.port(b.input).state() { + PortState::Uninferred => return Decision::Retry, + PortState::Unresolved => { + return Decision::fail( + b.output, + Diagnostic::ResolveError { + name: "(upstream failure in branch condition)".to_string(), + span: b.span.clone(), + correction: Correction::deferred_for_diagnostic_class( + "InferenceDiagnostic", + ), + }, + ); + } + PortState::Resolved(ty) => { + if walk_to_disj_decl(dag, ty.declaration).is_none() + && walk_to_optional_cardinality_decl(dag, ty.declaration).is_none() + { + if is_retryable_generic_decl(dag, ty.declaration) { + return Decision::Retry; + } + let Some(bool_ty) = dag.bool_shape() else { + return Decision::fail( + b.output, + Diagnostic::ResolveError { + name: "primitive `Bool` missing from declaration table — bootstrap failed" + .to_string(), + span: b.span.clone(), + correction: Correction::deferred_for_diagnostic_class("InferenceDiagnostic"), + }, + ); + }; + return Decision::fail( + b.output, + Diagnostic::TypeMismatch { + expected: bool_ty, + actual: *ty, + span: b.span.clone(), + correction: Correction::deferred_for_diagnostic_class( + "InferenceDiagnostic", + ), + }, + ); + } + } + } + + let mut iter = b.paths.iter(); + let Some(first_path) = iter.next() else { + return Decision::Retry; + }; + let first_type = match dag.port(first_path.output).state() { + PortState::Uninferred => return Decision::Retry, + PortState::Unresolved => { + return Decision::fail( + b.output, + Diagnostic::ResolveError { + name: "(upstream failure in branch path)".to_string(), + span: b.span.clone(), + correction: Correction::deferred_for_diagnostic_class( + "InferenceDiagnostic", + ), + }, + ); + } + PortState::Resolved(t) => *t, + }; + for path in iter { + match dag.port(path.output).state() { + PortState::Uninferred => return Decision::Retry, + PortState::Unresolved => { + return Decision::fail( + b.output, + Diagnostic::ResolveError { + name: "(upstream failure in branch path)".to_string(), + span: b.span.clone(), + correction: Correction::deferred_for_diagnostic_class( + "InferenceDiagnostic", + ), + }, + ); + } + PortState::Resolved(other) + if type_shapes_equivalent(dag, other, &first_type) => {} + PortState::Resolved(other) => { + return Decision::fail( + b.output, + Diagnostic::TypeMismatch { + expected: first_type, + actual: *other, + span: b.span.clone(), + correction: Correction::deferred_for_diagnostic_class( + "InferenceDiagnostic", + ), + }, + ); + } + } + } + Decision::Set(b.output, first_type) + } + Behavior::Loop(l) => { + if l.body == l.id { + return Decision::Retry; + } + let body_node = dag.node(l.body); + let body_output = behavior_output_port(body_node); + match dag.port(body_output).state() { + PortState::Uninferred => Decision::Retry, + PortState::Unresolved => Decision::fail( + l.output, + Diagnostic::ResolveError { + name: "function body is unresolved".to_string(), + span: l.span.clone(), + correction: Correction::deferred_for_diagnostic_class( + "InferenceDiagnostic", + ), + }, + ), + PortState::Resolved(body_ty) => Decision::Set(l.output, *body_ty), + } + } + Behavior::Bind(_) => Decision::Retry, + } +} + +fn decide_transform(dag: &mut Dag, t: &TransformNode) -> Decision { + // Structural dispatch on `TransformTarget`. `Operator` carries the + // resolved `OperatorKind` directly and produces its signature from + // the LHS operand type (arithmetic returns operand type, comparison + // returns Bool). `Callable` points at a DeclarationId and walks + // `resolve_arrow` through any Instantiation / ResolvedIdentifier + // chain to recover the concrete Arrow signature. + let signature = match &t.target { + TransformTarget::UnresolvedFieldProject { field_label } => { + return decide_field_project(dag, t, field_label); + } + TransformTarget::ResolvedFieldProject { field_label } => { + return decide_field_project(dag, t, field_label); + } + TransformTarget::Operator(op_kind) => { + let lhs_type = match t.inputs.first() { + None => { + return Decision::fail( + t.output, + Diagnostic::ArityMismatch { + function: crate::operators::symbol(*op_kind), + expected: 2, + actual: 0, + span: t.span.clone(), + correction: Correction::deferred_for_diagnostic_class( + "InferenceDiagnostic", + ), + }, + ); + } + Some(port) => match dag.port(*port).state() { + PortState::Uninferred => return Decision::Retry, + PortState::Unresolved => { + return Decision::fail( + t.output, + Diagnostic::ResolveError { + name: format!( + "(upstream failure in {})", + crate::operators::symbol(*op_kind) + ), + span: t.span.clone(), + correction: Correction::deferred_for_diagnostic_class( + "InferenceDiagnostic", + ), + }, + ); + } + PortState::Resolved(ty) => *ty, + }, + }; + if is_retryable_generic_decl(dag, lhs_type.declaration) { + return Decision::Retry; + } + match resolve_operator_arrow(dag, *op_kind, &lhs_type) { + Some(sig) => sig, + None => { + if is_retryable_generic_decl(dag, lhs_type.declaration) { + return Decision::Retry; + } + return Decision::fail( + t.output, + Diagnostic::ResolveError { + name: format!( + "cannot dispatch operator `{}` on {lhs_type:?}", + crate::operators::symbol(*op_kind) + ), + span: t.span.clone(), + correction: Correction::deferred_for_diagnostic_class( + "InferenceDiagnostic", + ), + }, + ); + } + } + } + TransformTarget::Callable(target_id) => { + match resolve_callable_target(dag, *target_id, &t.inputs, &t.span) { + CallableTargetResolution::Retry => return Decision::Retry, + CallableTargetResolution::Fail(diag) => return Decision::fail(t.output, diag), + CallableTargetResolution::Resolved { signature, .. } => signature, + } + } + }; + + // Arrow-body state check. The five variants partition into the + // "check something" cases (UserDefined → Bind.value port state, + // ExternalRealization → realization shape) and the "no body to + // walk" cases (Pending/NoBody/Unparsed). Each failure surfaces as + // a fail-closed diagnostic on the call site's output port. + match &signature.body { + ArrowBody::UserDefined(bind_id) => { + // User function: the call site's signature is only trustworthy + // once the function's Bind.value port has reached Resolved. + // Uninferred → Retry; Unresolved → fail the call site. + match dag.port((*bind_id).bind(dag).value).state() { + PortState::Uninferred => return Decision::Retry, + PortState::Unresolved => { + let name = transform_target_display_name(dag, &t.target); + return Decision::fail( + t.output, + Diagnostic::ResolveError { + name: format!("function `{name}` has an invalid body"), + span: t.span.clone(), + correction: Correction::deferred_for_diagnostic_class( + "InferenceDiagnostic", + ), + }, + ); + } + PortState::Resolved(_) => {} + } + } + ArrowBody::ExternalRealization(realization_id) => { + // Primitive realization: the target declaration must be a + // Conj whose `meta_tag` edge points at the cached + // `Realization` meta-type (populated at bootstrap). The + // typed-edge check was asserted at bootstrap construction + // (`assert_realization_shape`); re-validating here catches + // drift from any future mutation path that bypasses the + // construction-time invariant. + if !is_realization_shape(dag, *realization_id) { + let name = transform_target_display_name(dag, &t.target); + return Decision::fail( + t.output, + Diagnostic::ResolveError { + name: format!( + "arrow `{name}` carries an ExternalRealization body whose target is not a realization declaration" + ), + span: t.span.clone(), + correction: Correction::deferred_for_diagnostic_class("InferenceDiagnostic"), + }, + ); + } + } + ArrowBody::Pending | ArrowBody::NoBody => { + // Both shapes mean "no executable body to walk." + // `Pending` is the remaining transient scaffold from + // `seed_function_signature` during lowering; `NoBody` + // is the terminal "no body by construction" form used + // by arrow types and other non-executable carriers. + // `decide_transform` treats them identically: signature + // inhabitance accepts; body walking is skipped. The + // variant distinction exists so + // `lens_structural_resolution` can treat any surviving + // `Arrow(Pending)` in the final Dag as an R13-class body- + // patching regression. + } + ArrowBody::Unparsed(_) => { + // Surface-grammar scaffold: signature type-checks, + // body is not yet parseable under the M1(2.7) grammar. + // Callers can dispatch against the signature; the body + // source span is preserved for M2+ parser extensions. + // Dissolves when block bodies become fully parseable. + } + } + + if signature.inputs.len() != t.inputs.len() { + return Decision::fail( + t.output, + Diagnostic::ArityMismatch { + function: transform_target_display_name(dag, &t.target), + expected: signature.inputs.len(), + actual: t.inputs.len(), + span: t.span.clone(), + correction: Correction::deferred_for_diagnostic_class("InferenceDiagnostic"), + }, + ); + } + for (input_port, expected_ty) in t.inputs.iter().zip(signature.inputs.iter()) { + match dag.port(*input_port).state() { + PortState::Uninferred => return Decision::Retry, + PortState::Unresolved => { + return Decision::fail( + t.output, + Diagnostic::ResolveError { + name: format!( + "(upstream failure in {})", + transform_target_display_name(dag, &t.target) + ), + span: t.span.clone(), + correction: Correction::deferred_for_diagnostic_class( + "InferenceDiagnostic", + ), + }, + ); + } + PortState::Resolved(actual) => { + let types_equivalent = type_shapes_equivalent(dag, actual, expected_ty); + if !types_equivalent + && (is_retryable_generic_decl(dag, actual.declaration) + || is_retryable_generic_decl(dag, expected_ty.declaration)) + { + return Decision::Retry; + } + if let Some(diag) = phantom_unit_mismatch( + dag, + transform_target_display_name(dag, &t.target), + expected_ty, + actual, + &t.span, + ) { + return Decision::fail(t.output, diag); + } + if !types_equivalent { + if let Some(literal) = literal_bigint_at(dag, *input_port) { + match int_literal_fits_expected_type(dag, &literal, expected_ty.declaration) + { + Ok(Some(true)) => { + if let Some(diag) = check_refinement_discharge( + dag, + actual, + expected_ty, + &t.target, + &t.span, + ) { + return Decision::fail(t.output, diag); + } + // Default literal shape is `Int`; align the argument port with + // the callee's narrow range-backed type (same as `let` / `data` + // pre-seed + `Decision::Set` reunion for annotated literals). + return Decision::Set(*input_port, *expected_ty); + } + Ok(Some(false)) | Ok(None) => {} + Err(diag) => return Decision::fail(t.output, diag), + } + match integer_range_for_decl(dag, expected_ty.declaration) { + IntegerRangeLookup::Found(range) => { + return Decision::fail( + *input_port, + magnitude_out_of_range_for_interval( + &literal, + *expected_ty, + range, + t.span.clone(), + ), + ); + } + IntegerRangeLookup::Invalid(diag) => { + return Decision::fail(t.output, diag); + } + IntegerRangeLookup::Missing => {} + } + } + return Decision::fail( + t.output, + Diagnostic::TypeMismatch { + expected: *expected_ty, + actual: *actual, + span: t.span.clone(), + correction: Correction::deferred_for_diagnostic_class( + "InferenceDiagnostic", + ), + }, + ); + } + // DB-11 (3a.3) refinement discharge. Structural type + // equivalence just passed; now check that any refinement + // declared on the callee's parameter type is also carried + // by the argument's port type, with structurally-equal + // predicate expression DAGs. No SMT, no implication — if + // the predicates don't walk equal, the caller must + // introduce a narrowing Branch that does satisfy the + // expected refinement. + if let Some(diag) = + check_refinement_discharge(dag, actual, expected_ty, &t.target, &t.span) + { + return Decision::fail(t.output, diag); + } + } + } + } + Decision::Set(t.output, signature.output) +} + +fn phantom_unit_mismatch( + dag: &Dag, + context: String, + expected_ty: &TypeShape, + actual_ty: &TypeShape, + span: &SourceSpan, +) -> Option { + let expected_inst = instantiation_parts(dag, expected_ty.declaration)?; + let actual_inst = instantiation_parts(dag, actual_ty.declaration)?; + let expected_template = dag.declaration(expected_inst.template); + for phantom in &expected_template.phantom_params { + if let Err(diag) = + require_abelian_group_phantom_algebra(dag, phantom.algebra, phantom.parameter, span) + { + return Some(diag); + } + if !expected_template.type_params.contains(&phantom.parameter) { + return Some(malformed_phantom_parameter_diagnostic( + dag, + expected_inst.template, + phantom.parameter, + span, + )); + } + let Some(actual_phantom) = + mapped_phantom_parameter(dag, expected_inst.template, phantom, actual_inst.template) + else { + continue; + }; + if let Err(diag) = require_abelian_group_phantom_algebra( + dag, + actual_phantom.algebra, + actual_phantom.parameter, + span, + ) { + return Some(diag); + } + if !phantom_algebras_equivalent(dag, phantom.algebra, actual_phantom.algebra) { + return Some(unsupported_phantom_algebra_diagnostic( + dag, + actual_phantom.algebra, + actual_phantom.parameter, + span, + )); + } + let Some(expected_arg) = expected_inst + .arguments + .iter() + .find(|arg| arg.parameter == phantom.parameter) + else { + return Some(missing_phantom_argument_diagnostic( + dag, + expected_inst.template, + phantom.parameter, + "expected", + span, + )); + }; + let Some(actual_arg) = actual_inst + .arguments + .iter() + .find(|arg| arg.parameter == actual_phantom.parameter) + else { + return Some(missing_phantom_argument_diagnostic( + dag, + actual_inst.template, + actual_phantom.parameter, + "actual", + span, + )); + }; + if expected_arg.value != actual_arg.value { + return Some(Diagnostic::UnitMismatch { + operator: context, + parameter: phantom_parameter_display_name(dag, phantom.parameter), + expected: TypeShape::new(expected_arg.value), + actual: TypeShape::new(actual_arg.value), + span: span.clone(), + correction: Correction::deferred_for_diagnostic_class("InferenceDiagnostic"), + }); + } + } + None +} + +fn malformed_phantom_parameter_diagnostic( + dag: &Dag, + template: DeclarationId, + parameter: DeclarationId, + span: &SourceSpan, +) -> Diagnostic { + Diagnostic::ResolveError { + name: format!( + "malformed phantom parameter {} on {}: parameter is not in type_params", + phantom_parameter_display_name(dag, parameter), + declaration_display_name(dag, template), + ), + span: span.clone(), + correction: Correction::deferred_for_diagnostic_class("InferenceDiagnostic"), + } +} + +fn missing_phantom_argument_diagnostic( + dag: &Dag, + template: DeclarationId, + parameter: DeclarationId, + side: &str, + span: &SourceSpan, +) -> Diagnostic { + Diagnostic::ResolveError { + name: format!( + "malformed phantom instantiation for {}: missing {side} argument for phantom parameter {}", + declaration_display_name(dag, template), + phantom_parameter_display_name(dag, parameter), + ), + span: span.clone(), + correction: Correction::deferred_for_diagnostic_class("InferenceDiagnostic"), + } +} + +fn unsupported_phantom_algebra_diagnostic( + dag: &Dag, + algebra: DeclarationId, + parameter: DeclarationId, + span: &SourceSpan, +) -> Diagnostic { + Diagnostic::ResolveError { + name: format!( + "unsupported phantom algebra {} for phantom parameter {}", + declaration_display_name(dag, algebra), + phantom_parameter_display_name(dag, parameter), + ), + span: span.clone(), + correction: Correction::deferred_for_diagnostic_class("InferenceDiagnostic"), + } +} + +fn require_abelian_group_phantom_algebra( + dag: &Dag, + algebra: DeclarationId, + parameter: DeclarationId, + span: &SourceSpan, +) -> Result<(), Diagnostic> { + let Some(canonical_abelian_group) = dag.abelian_group_decl() else { + return Err(unsupported_phantom_algebra_diagnostic( + dag, algebra, parameter, span, + )); + }; + let mut current = algebra; + for _ in 0..WALK_DEPTH_LIMIT { + let decl = dag.declaration(current); + if current == canonical_abelian_group { + return Ok(()); + } + match &decl.connective { + TypeConnective::Instantiation { template, .. } => { + current = *template; + } + TypeConnective::Atom(AtomPayload::ResolvedByStructure(next)) + | TypeConnective::Atom(AtomPayload::ResolvedByName(next)) => { + current = *next; + } + _ => { + return Err(unsupported_phantom_algebra_diagnostic( + dag, algebra, parameter, span, + )); + } + } + } + Err(unsupported_phantom_algebra_diagnostic( + dag, algebra, parameter, span, + )) +} + +fn phantom_algebras_equivalent( + dag: &Dag, + lhs_algebra: DeclarationId, + rhs_algebra: DeclarationId, +) -> bool { + lhs_algebra == rhs_algebra || declaration_shapes_equivalent(dag, lhs_algebra, rhs_algebra, 0) +} + +fn mapped_phantom_parameter( + dag: &Dag, + source_template: DeclarationId, + source_phantom: &PhantomParameter, + target_template: DeclarationId, +) -> Option { + let source_decl = dag.declaration(source_template); + let target_decl = dag.declaration(target_template); + let source_index = source_decl + .type_params + .iter() + .position(|parameter| *parameter == source_phantom.parameter)?; + let target_parameter = target_decl.type_params.get(source_index).copied()?; + target_decl + .phantom_params + .iter() + .find(|target_phantom| target_phantom.parameter == target_parameter) + .cloned() +} + +struct InstantiationParts<'a> { + template: DeclarationId, + arguments: &'a [crate::dag::TemplateArgument], +} + +fn instantiation_parts(dag: &Dag, declaration: DeclarationId) -> Option> { + match &dag.declaration(declaration).connective { + TypeConnective::Instantiation { + template, + arguments, + } => Some(InstantiationParts { + template: *template, + arguments, + }), + _ => None, + } +} + +fn phantom_parameter_display_name(dag: &Dag, parameter: DeclarationId) -> String { + match &dag.declaration(parameter).connective { + TypeConnective::Atom(AtomPayload::TypeParam(name)) => name.clone(), + _ => declaration_display_name(dag, parameter), + } +} + +/// DB-11 (3a.3) call-site refinement discharge. Returns `None` when +/// the expected parameter has no refinement (unconditionally OK), or +/// when the actual argument's top-level refinement discharges the +/// expected predicate. Returns a `Diagnostic` otherwise. Pure +/// structural walk — no SMT, no entailment beyond conjunction-member +/// matching against the canonical composite form. +/// +/// Composite-canonical invariant (post-narrowing refactor): every +/// refinement in the DAG is a single predicate Declaration. Narrowing +/// produces a composite `outer_body && new_body` rather than an alias +/// chain of unary refinements, so discharge only has to look at the +/// top-level refinement on `actual.declaration` — no chain walk. The +/// composite-vs-single asymmetry is handled by +/// `predicate_discharges`, which accepts either a full structural +/// match against the expected predicate or a match against a +/// conjunct of `actual`'s composite body. +fn check_refinement_discharge( + dag: &Dag, + actual: &TypeShape, + expected: &TypeShape, + target: &TransformTarget, + span: &SourceSpan, +) -> Option { + let expected_pred = dag.declaration(expected.declaration).refinement?; + let actual_pred = dag.declaration(actual.declaration).refinement; + match actual_pred { + Some(actual_pred) if predicate_discharges(dag, actual_pred, expected_pred, 0) => None, + _ => { + let callee = transform_target_display_name(dag, target); + let name = if actual_pred.is_some() { + format!( + "argument to `{callee}` does not satisfy the expected \ + `where` refinement (caller's predicate does not walk \ + structurally equal to the callee's, and no conjunct \ + of the caller's composite matches)" + ) + } else { + format!( + "argument to `{callee}` does not satisfy the expected \ + `where` refinement — no narrowing branch in scope" + ) + }; + Some(Diagnostic::ResolveError { + name, + span: span.clone(), + correction: Correction::deferred_for_diagnostic_class("InferenceDiagnostic"), + }) + } + } +} + +/// Structural discharge check between an actual and expected +/// predicate declaration. Succeeds when: +/// +/// - The two predicates are the same declaration id; OR +/// - The actual predicate's body walks structurally equal to the +/// expected predicate's body (param-paired); OR +/// - The actual predicate's body is a `Transform(Logical(And), [a, b])` +/// and either `a` or `b` (as a virtual sub-predicate sharing the +/// actual's `Bind.params[0]`) discharges the expected predicate. +/// +/// The composite conjunct case is the load-bearing piece that lets +/// the post-narrowing composite `outer_body && new_body` match a +/// callee whose refinement is just `outer_body`. Pure structural, +/// depth-bounded. +fn predicate_discharges( + dag: &Dag, + actual_pred_decl: DeclarationId, + expected_pred_decl: DeclarationId, + depth: usize, +) -> bool { + if depth >= WALK_DEPTH_LIMIT { + return false; + } + if actual_pred_decl == expected_pred_decl { + return true; + } + let Some((actual_param, actual_body)) = predicate_info(dag, actual_pred_decl) else { + return false; + }; + let Some((expected_param, expected_body)) = predicate_info(dag, expected_pred_decl) else { + return false; + }; + body_discharges( + dag, + actual_body, + expected_body, + actual_param, + expected_param, + ) +} + +/// Flatten-and-subset discharge check. A predicate body is treated +/// as a CONJUNCTION OF LEAVES — the maximal multiset of non-`And` +/// operand ports reachable by recursively unfolding every +/// `Transform(Logical(And), [lhs, rhs])` root. Actual discharges +/// expected iff every expected leaf is walk-equal (param-paired) to +/// some actual leaf. +/// +/// This makes conjunction associativity / grouping irrelevant to +/// discharge: `a && (b && c)`, `(a && b) && c`, and `a && b && c` +/// all flatten to the same leaf set `{a, b, c}` and therefore +/// discharge each other symmetrically. Without the flattening, +/// `body_discharges` could only recurse into one root conjunct of +/// the actual side at a time, so an asymmetric grouping would fail +/// to discharge a syntax-equivalent callee contract (the +/// substrate-level blocker ChatGPT's R5 review called out on +/// `31a3709d`). +/// +/// Still pure structural — no SMT, no absorption laws, no ordering +/// reasoning. The "canonical form" is the leaf multiset; two bodies +/// representing the same conjunction share one leaf multiset up to +/// reordering, and the subset check tolerates the multiset shape. +fn body_discharges( + dag: &Dag, + actual_body: PortId, + expected_body: PortId, + actual_param: PortId, + expected_param: PortId, +) -> bool { + let mut actual_leaves: Vec = Vec::new(); + collect_conjunct_leaves(dag, actual_body, &mut actual_leaves, 0); + let mut expected_leaves: Vec = Vec::new(); + collect_conjunct_leaves(dag, expected_body, &mut expected_leaves, 0); + expected_leaves.into_iter().all(|expected_leaf| { + actual_leaves.iter().any(|&actual_leaf| { + refinement_ports_equal( + dag, + actual_leaf, + expected_leaf, + actual_param, + expected_param, + 0, + ) + }) + }) +} + +/// Recursively unfold `Transform(Logical(And), [lhs, rhs])` roots +/// and collect the non-`And` leaves. Depth-bounded by +/// `WALK_DEPTH_LIMIT`; on overflow the current port is pushed as a +/// leaf so discharge degrades to the pre-flatten shape rather than +/// silently dropping conjuncts. +fn collect_conjunct_leaves(dag: &Dag, body_port: PortId, out: &mut Vec, depth: usize) { + if depth >= WALK_DEPTH_LIMIT { + out.push(body_port); + return; + } + if let Some(node_id) = dag.port(body_port).produced_by { + if let Behavior::Transform(t) = dag.node(node_id) { + if matches!( + &t.target, + TransformTarget::Operator(OperatorKind::Logical(LogicalOp::And)) + ) && t.inputs.len() == 2 + { + collect_conjunct_leaves(dag, t.inputs[0], out, depth + 1); + collect_conjunct_leaves(dag, t.inputs[1], out, depth + 1); + return; + } + } + } + out.push(body_port); +} + +fn predicate_info(dag: &Dag, pred_decl: DeclarationId) -> Option<(PortId, PortId)> { + if let TypeConnective::Arrow { + body: ArrowBody::UserDefined(bind_id), + .. + } = &dag.declaration(pred_decl).connective + { + let bind = (*bind_id).bind(dag); + if let Some(param_port) = bind.params.first() { + return Some((*param_port, bind.value)); + } + } + None +} + +fn refinement_ports_equal( + dag: &Dag, + lhs_port: PortId, + rhs_port: PortId, + lhs_param: PortId, + rhs_param: PortId, + depth: usize, +) -> bool { + if depth >= WALK_DEPTH_LIMIT { + return false; + } + let lhs_is_param = lhs_port == lhs_param; + let rhs_is_param = rhs_port == rhs_param; + if lhs_is_param || rhs_is_param { + return lhs_is_param && rhs_is_param; + } + let Some(lhs_nid) = dag.port(lhs_port).produced_by else { + return lhs_port == rhs_port; + }; + let Some(rhs_nid) = dag.port(rhs_port).produced_by else { + return false; + }; + match (dag.node(lhs_nid), dag.node(rhs_nid)) { + (Behavior::Value(lv), Behavior::Value(rv)) => lv.data == rv.data, + (Behavior::Transform(lt), Behavior::Transform(rt)) => { + refinement_targets_equal(dag, <.target, &rt.target) + && lt.inputs.len() == rt.inputs.len() + && lt.inputs.iter().zip(rt.inputs.iter()).all(|(l, r)| { + refinement_ports_equal(dag, *l, *r, lhs_param, rhs_param, depth + 1) + }) + } + _ => false, + } +} + +fn refinement_targets_equal(dag: &Dag, lhs: &TransformTarget, rhs: &TransformTarget) -> bool { + match (lhs, rhs) { + // DB-11 (3a.3) structural callable identity. Call lowering + // materializes a fresh `Instantiation` declaration per + // call-site when the callee has retained template arguments + // (see `retained_template_arguments_for_target` in lower.rs). + // Two syntactically identical calls to a generic predicate + // (e.g., `is_eq(d, 0)`) from two different refinement + // contexts therefore carry different `Callable(DeclarationId)` + // targets even though their template + argument values match. + // Nominal id equality would reject those as distinct; the + // authoritative identity is template + substituted arguments, + // which `declaration_shapes_equivalent` already walks through + // `Instantiation`/`ResolvedIdentifier` edges. + (TransformTarget::Callable(a), TransformTarget::Callable(b)) => { + declaration_shapes_equivalent(dag, *a, *b, 0) + } + (TransformTarget::Operator(a), TransformTarget::Operator(b)) => a == b, + ( + TransformTarget::UnresolvedFieldProject { + field_label: a_label, + }, + TransformTarget::UnresolvedFieldProject { + field_label: b_label, + }, + ) => a_label == b_label, + ( + TransformTarget::ResolvedFieldProject { + field_label: a_label, + }, + TransformTarget::ResolvedFieldProject { + field_label: b_label, + }, + ) => a_label == b_label, + _ => false, + } +} + +struct ResolvedArrow { + inputs: Vec, + output: TypeShape, + body: ArrowBody, +} + +struct ResolvedArrowDecl { + inputs: Vec, + output: DeclarationId, +} + +#[derive(Clone)] +struct PortTypeContext { + decl: DeclarationId, + subst: SubstStack, +} + +struct CallableSignatureContext { + inputs: Vec, + output: PortTypeContext, +} + +enum CallableTargetResolution { + Retry, + Fail(Diagnostic), + Resolved { + template: DeclarationId, + arguments: Vec, + signature: ResolvedArrow, + }, +} + +enum CallableBindingResolution { + Resolved, + Retry, + Conflict, +} + +/// Lazy substitution stack for `Instantiation` walks. When inference +/// descends into an `Instantiation { template, arguments }`, it pushes +/// `arguments` onto this stack; when a downstream `TypeParam` reference +/// is encountered, the stack is consulted top-down to find the bound +/// `DeclarationId`. Pop on Instantiation exit keeps the stack balanced. +/// See M1_DESIGN.md §4 Q4 / §5 for the walk semantics. +#[derive(Clone)] +pub(crate) struct SubstStack { + frames: Vec>, +} + +impl SubstStack { + pub(crate) fn new() -> Self { + Self { frames: Vec::new() } + } + + pub(crate) fn push(&mut self, args: Vec) { + self.frames.push(args); + } + + pub(crate) fn pop(&mut self) { + self.frames.pop(); + } + + pub(crate) fn lookup(&self, param_id: DeclarationId) -> Option { + for frame in self.frames.iter().rev() { + for arg in frame { + if arg.parameter == param_id { + if arg.value == param_id { + return None; + } + return Some(arg.value); + } + } + } + None + } +} + +enum PayloadBindingResolution { + Direct(TypeShape), + SpecializedRecord { + variant_decl_id: DeclarationId, + subst: SubstStack, + }, +} + +fn declaration_is_callable(dag: &Dag, current: DeclarationId, depth: usize) -> bool { + if depth >= WALK_DEPTH_LIMIT { + return false; + } + match &dag.declaration(current).connective { + TypeConnective::Arrow { .. } => true, + TypeConnective::Instantiation { template, .. } => { + declaration_is_callable(dag, *template, depth + 1) + } + TypeConnective::Atom(AtomPayload::ResolvedByStructure(next)) + | TypeConnective::Atom(AtomPayload::ResolvedByName(next)) => { + declaration_is_callable(dag, *next, depth + 1) + } + TypeConnective::Atom(AtomPayload::UnresolvedIdentifier(_)) + | TypeConnective::Atom(AtomPayload::TypeParam(_)) + | TypeConnective::Atom(AtomPayload::Literal(_)) + | TypeConnective::Conj { .. } + | TypeConnective::Disj { .. } + | TypeConnective::Cardinality(_) => false, + } +} + +fn is_retryable_generic_decl(dag: &Dag, current: DeclarationId) -> bool { + let mut visiting = Vec::new(); + is_retryable_generic_decl_walk(dag, current, 0, &mut visiting) +} + +fn is_retryable_generic_decl_walk( + dag: &Dag, + current: DeclarationId, + depth: usize, + visiting: &mut Vec, +) -> bool { + if depth >= WALK_DEPTH_LIMIT { + return false; + } + if visiting.contains(¤t) { + return false; + } + visiting.push(current); + let retryable = match &dag.declaration(current).connective { + TypeConnective::Atom(AtomPayload::TypeParam(_)) => true, + TypeConnective::Atom(AtomPayload::ResolvedByStructure(next)) + | TypeConnective::Atom(AtomPayload::ResolvedByName(next)) => { + is_retryable_generic_decl_walk(dag, *next, depth + 1, visiting) + } + TypeConnective::Instantiation { arguments, .. } => arguments + .iter() + .any(|arg| is_retryable_generic_decl_walk(dag, arg.value, depth + 1, visiting)), + TypeConnective::Cardinality(p) => { + is_retryable_generic_decl_walk(dag, p.element(), depth + 1, visiting) + } + TypeConnective::Conj { children } => children + .iter() + .any(|field| is_retryable_generic_decl_walk(dag, field.ty, depth + 1, visiting)), + TypeConnective::Disj { variants } => variants + .iter() + .any(|field| is_retryable_generic_decl_walk(dag, field.ty, depth + 1, visiting)), + TypeConnective::Atom(AtomPayload::UnresolvedIdentifier(_)) + | TypeConnective::Atom(AtomPayload::Literal(_)) + | TypeConnective::Arrow { .. } => false, + }; + visiting.pop(); + retryable +} + +fn callable_template_arguments( + dag: &Dag, + target: DeclarationId, +) -> (DeclarationId, Vec) { + match &dag.declaration(target).connective { + TypeConnective::Instantiation { + template, + arguments, + } => (*template, arguments.clone()), + _ => (target, Vec::new()), + } +} + +fn template_argument_value( + arguments: &[TemplateArgument], + parameter: DeclarationId, +) -> Option { + match generated_template_argument_value(arguments, ¶meter) { + Lookup::Hit(value) => Some(value), + Lookup::Miss => None, + } +} + +fn resolve_template_argument_value( + arguments: &[TemplateArgument], + current: DeclarationId, + depth: usize, +) -> DeclarationId { + generated_resolve_template_argument_value( + &(WALK_DEPTH_LIMIT.saturating_sub(depth) as i64), + arguments, + current, + ) +} + +fn retained_template_arguments_for_target( + dag: &Dag, + template: DeclarationId, + arguments: &[TemplateArgument], +) -> Vec { + let mut allowed: HashSet = dag + .declaration(template) + .type_params + .iter() + .copied() + .collect(); + if let Some(enclosing_disj) = enclosing_disj_for_variant(dag, template) { + allowed.extend(dag.declaration(enclosing_disj).type_params.iter().copied()); + } + if let Some(raw_arrow) = resolve_arrow_decl_walk(dag, template, &mut SubstStack::new(), 0) { + for input in raw_arrow.inputs { + if declaration_is_callable(dag, input, 0) { + allowed.insert(input); + } + } + } + + let mut retained: Vec = Vec::new(); + for argument in arguments { + if !allowed.contains(&argument.parameter) { + continue; + } + let resolved_value = resolve_template_argument_value(arguments, argument.value, 0); + if let Some(existing) = retained + .iter_mut() + .find(|existing| existing.parameter == argument.parameter) + { + existing.value = resolved_value; + continue; + } + retained.push(TemplateArgument { + parameter: argument.parameter, + value: resolved_value, + }); + } + retained +} + +fn push_template_argument_binding( + arguments: &mut Vec, + parameter: DeclarationId, + value: DeclarationId, +) -> bool { + match generated_push_template_argument_binding(arguments, ¶meter, value) { + TemplateArgumentBinding::Conflict => false, + TemplateArgumentBinding::NoOp => true, + TemplateArgumentBinding::Append => { + arguments.push(TemplateArgument { parameter, value }); + true + } + TemplateArgumentBinding::ReplaceAt { + _0: index, + _1: updated, + } => { + let Some(existing) = arguments.get_mut(index as usize) else { + return false; + }; + existing.value = updated; + true + } + } +} + +fn resolve_arrow_decl_walk( + dag: &Dag, + current: DeclarationId, + subst: &mut SubstStack, + depth: usize, +) -> Option { + if depth >= WALK_DEPTH_LIMIT { + return None; + } + let decl = dag.declaration(current); + match &decl.connective { + TypeConnective::Arrow { inputs, output, .. } => Some(ResolvedArrowDecl { + inputs: inputs.clone(), + output: *output, + }), + TypeConnective::Instantiation { + template, + arguments, + } => { + subst.push(arguments.clone()); + let result = resolve_arrow_decl_walk(dag, *template, subst, depth + 1); + subst.pop(); + result + } + TypeConnective::Atom(AtomPayload::ResolvedByStructure(next)) + | TypeConnective::Atom(AtomPayload::ResolvedByName(next)) => { + resolve_arrow_decl_walk(dag, *next, subst, depth + 1) + } + TypeConnective::Atom(AtomPayload::TypeParam(_)) => { + let bound = subst.lookup(current)?; + resolve_arrow_decl_walk(dag, bound, subst, depth + 1) + } + TypeConnective::Atom(AtomPayload::UnresolvedIdentifier(_)) + | TypeConnective::Atom(AtomPayload::Literal(_)) + | TypeConnective::Conj { .. } + | TypeConnective::Disj { .. } + | TypeConnective::Cardinality(_) => None, + } +} + +fn port_type_context(dag: &Dag, port: PortId) -> Option { + let resolved_decl = match dag.port(port).state() { + PortState::Resolved(ty) => ty.declaration, + PortState::Uninferred | PortState::Unresolved => return None, + }; + let Some(produced_by) = dag.port(port).produced_by else { + return Some(PortTypeContext { + decl: resolved_decl, + subst: SubstStack::new(), + }); + }; + match dag.node(produced_by) { + // Use the port's resolved declaration, not the literal's default + // substrate type (`Int` for all int literals). After range narrowing the + // literal port may already be `UInt8` / etc.; `literal_decl_id`-style + // `Int` here makes `resolve_callable_target` think the argument is still + // the default `Int`, producing spurious implicit-template conflicts against + // a `UInt8` parameter (e.g. `id_u8(7)`). + Behavior::Value(_) => Some(PortTypeContext { + decl: resolved_decl, + subst: SubstStack::new(), + }), + Behavior::Transform(t) => match &t.target { + TransformTarget::Callable(target) => { + let CallableTargetResolution::Resolved { + template, + arguments, + .. + } = resolve_callable_target(dag, *target, &t.inputs, &t.span) + else { + return Some(PortTypeContext { + decl: resolved_decl, + subst: SubstStack::new(), + }); + }; + let mut subst = SubstStack::new(); + subst.push(arguments); + let Some(arrow) = resolve_arrow_decl_walk(dag, template, &mut subst, 0) else { + if let Some(enclosing_disj) = enclosing_disj_for_variant(dag, template) { + return Some(PortTypeContext { + decl: enclosing_disj, + subst, + }); + } + return Some(PortTypeContext { + decl: resolved_decl, + subst: SubstStack::new(), + }); + }; + Some(PortTypeContext { + decl: arrow.output, + subst, + }) + } + TransformTarget::UnresolvedFieldProject { field_label } + | TransformTarget::ResolvedFieldProject { field_label } => { + field_project_port_type_context(dag, t, field_label) + } + TransformTarget::Operator(_) => Some(PortTypeContext { + decl: resolved_decl, + subst: SubstStack::new(), + }), + }, + Behavior::Branch(_) | Behavior::Loop(_) | Behavior::Bind(_) => Some(PortTypeContext { + decl: resolved_decl, + subst: SubstStack::new(), + }), + } +} + +fn field_project_port_type_context( + dag: &Dag, + t: &TransformNode, + field_label: &str, +) -> Option { + if t.inputs.len() != 1 { + return None; + } + let input_ty = match dag.port(t.inputs[0]).state() { + PortState::Resolved(ty) => *ty, + PortState::Uninferred | PortState::Unresolved => return None, + }; + let mut subst = SubstStack::new(); + let conj_id = walk_to_conj_decl_with_subst(dag, input_ty.declaration, &mut subst)?; + let TypeConnective::Conj { children } = &dag.declaration(conj_id).connective else { + return None; + }; + let field_decl = children + .iter() + .find(|field| field.label == field_label) + .map(|field| field.ty)?; + Some(PortTypeContext { + decl: field_decl, + subst, + }) +} + +fn resolve_binding_decl( + dag: &Dag, + current: DeclarationId, + subst: &SubstStack, + depth: usize, +) -> Option { + if depth >= WALK_DEPTH_LIMIT { + return None; + } + match &dag.declaration(current).connective { + TypeConnective::Atom(AtomPayload::TypeParam(_)) => subst + .lookup(current) + .and_then(|bound| resolve_binding_decl(dag, bound, subst, depth + 1)) + .or_else(|| { + walk_to_type_shape(dag, current, subst, depth + 1).map(|ty| ty.declaration) + }), + TypeConnective::Atom(AtomPayload::ResolvedByStructure(next)) + | TypeConnective::Atom(AtomPayload::ResolvedByName(next)) => { + resolve_binding_decl(dag, *next, subst, depth + 1) + } + _ => walk_to_type_shape(dag, current, subst, depth + 1).map(|ty| ty.declaration), + } +} + +fn callable_signature_context( + dag: &Dag, + callable: DeclarationId, +) -> Option { + let decl = dag.declaration(callable); + if let TypeConnective::Arrow { + inputs, + body: ArrowBody::UserDefined(bind_id), + .. + } = &decl.connective + { + let bind = (*bind_id).bind(dag); + if bind.params.len() < inputs.len() { + return None; + } + let start = bind.params.len() - inputs.len(); + let mut input_contexts = Vec::with_capacity(inputs.len()); + for port in &bind.params[start..] { + input_contexts.push(port_type_context(dag, *port)?); + } + return Some(CallableSignatureContext { + inputs: input_contexts, + output: port_type_context(dag, bind.value)?, + }); + } + + let (template, arguments) = callable_template_arguments(dag, callable); + let mut subst = SubstStack::new(); + subst.push(arguments); + let raw_arrow = resolve_arrow_decl_walk(dag, template, &mut subst, 0)?; + Some(CallableSignatureContext { + inputs: raw_arrow + .inputs + .into_iter() + .map(|decl| PortTypeContext { + decl, + subst: subst.clone(), + }) + .collect(), + output: PortTypeContext { + decl: raw_arrow.output, + subst, + }, + }) +} + +fn bind_expected_callable_to_actual( + dag: &Dag, + expected_callable: DeclarationId, + actual_callable: DeclarationId, + arguments: &mut Vec, +) -> CallableBindingResolution { + let mut expected_subst = SubstStack::new(); + expected_subst.push(arguments.clone()); + let Some(expected_arrow) = + resolve_arrow_decl_walk(dag, expected_callable, &mut expected_subst, 0) + else { + return CallableBindingResolution::Conflict; + }; + let Some(actual_signature) = callable_signature_context(dag, actual_callable) else { + return CallableBindingResolution::Retry; + }; + if expected_arrow.inputs.len() != actual_signature.inputs.len() { + return CallableBindingResolution::Conflict; + } + for (expected_input, actual_input) in expected_arrow + .inputs + .into_iter() + .zip(actual_signature.inputs.iter()) + { + if !bind_expected_decl_to_actual_context(dag, expected_input, actual_input, arguments, 0) { + return CallableBindingResolution::Conflict; + } + } + if !bind_expected_decl_to_actual_context( + dag, + expected_arrow.output, + &actual_signature.output, + arguments, + 0, + ) { + return CallableBindingResolution::Conflict; + } + CallableBindingResolution::Resolved +} + +fn bind_expected_decl_to_actual_context( + dag: &Dag, + expected: DeclarationId, + actual: &PortTypeContext, + arguments: &mut Vec, + depth: usize, +) -> bool { + if depth >= WALK_DEPTH_LIMIT { + return false; + } + + match &dag.declaration(expected).connective { + TypeConnective::Atom(AtomPayload::TypeParam(_)) => { + if let Some(bound) = template_argument_value(arguments, expected) { + if bound != expected { + let expected_ctx = PortTypeContext { + decl: bound, + subst: SubstStack::new(), + }; + return bind_expected_decl_to_actual_context( + dag, + expected_ctx.decl, + actual, + arguments, + depth + 1, + ); + } + } + let Some(value) = resolve_binding_decl(dag, actual.decl, &actual.subst, depth + 1) + else { + return false; + }; + push_template_argument_binding(arguments, expected, value) + } + TypeConnective::Atom(AtomPayload::ResolvedByStructure(next)) + | TypeConnective::Atom(AtomPayload::ResolvedByName(next)) => { + bind_expected_decl_to_actual_context(dag, *next, actual, arguments, depth + 1) + } + TypeConnective::Instantiation { + template, + arguments: expected_args, + } => { + let actual_decl = match &dag.declaration(actual.decl).connective { + TypeConnective::Atom(AtomPayload::TypeParam(_)) => { + let Some(bound) = actual.subst.lookup(actual.decl) else { + return false; + }; + return bind_expected_decl_to_actual_context( + dag, + expected, + &PortTypeContext { + decl: bound, + subst: actual.subst.clone(), + }, + arguments, + depth + 1, + ); + } + TypeConnective::Atom(AtomPayload::ResolvedByStructure(next)) + | TypeConnective::Atom(AtomPayload::ResolvedByName(next)) => { + return bind_expected_decl_to_actual_context( + dag, + expected, + &PortTypeContext { + decl: *next, + subst: actual.subst.clone(), + }, + arguments, + depth + 1, + ); + } + _ => actual.decl, + }; + let TypeConnective::Instantiation { + template: actual_template, + arguments: actual_args, + } = &dag.declaration(actual_decl).connective + else { + if actual_decl != *template { + return false; + } + let template_params = dag.declaration(*template).type_params.clone(); + if template_params.len() != expected_args.len() { + return false; + } + for (expected_arg, param_id) in expected_args.iter().zip(template_params) { + let Some(actual_value) = actual.subst.lookup(param_id) else { + return false; + }; + if !bind_expected_decl_to_actual_context( + dag, + expected_arg.value, + &PortTypeContext { + decl: actual_value, + subst: actual.subst.clone(), + }, + arguments, + depth + 1, + ) { + return false; + } + } + return true; + }; + if *actual_template == expected && expected_args.len() == actual_args.len() { + for (expected_arg, actual_arg) in expected_args.iter().zip(actual_args.iter()) { + if !bind_expected_decl_to_actual_context( + dag, + expected_arg.value, + &PortTypeContext { + decl: actual_arg.value, + subst: actual.subst.clone(), + }, + arguments, + depth + 1, + ) { + return false; + } + } + return true; + } + if template != actual_template || expected_args.len() != actual_args.len() { + return false; + } + for (expected_arg, actual_arg) in expected_args.iter().zip(actual_args.iter()) { + if !bind_expected_decl_to_actual_context( + dag, + expected_arg.value, + &PortTypeContext { + decl: actual_arg.value, + subst: actual.subst.clone(), + }, + arguments, + depth + 1, + ) { + return false; + } + } + true + } + TypeConnective::Cardinality(p) => { + let element = p.element(); + let bound = p.bound(); + let actual_decl = match &dag.declaration(actual.decl).connective { + TypeConnective::Atom(AtomPayload::TypeParam(_)) => { + let Some(bound) = actual.subst.lookup(actual.decl) else { + return false; + }; + return bind_expected_decl_to_actual_context( + dag, + expected, + &PortTypeContext { + decl: bound, + subst: actual.subst.clone(), + }, + arguments, + depth + 1, + ); + } + TypeConnective::Atom(AtomPayload::ResolvedByStructure(next)) + | TypeConnective::Atom(AtomPayload::ResolvedByName(next)) => { + return bind_expected_decl_to_actual_context( + dag, + expected, + &PortTypeContext { + decl: *next, + subst: actual.subst.clone(), + }, + arguments, + depth + 1, + ); + } + _ => actual.decl, + }; + let TypeConnective::Cardinality(actual_p) = &dag.declaration(actual_decl).connective + else { + return false; + }; + if bound != actual_p.bound() { + return false; + } + bind_expected_decl_to_actual_context( + dag, + element, + &PortTypeContext { + decl: actual_p.element(), + subst: actual.subst.clone(), + }, + arguments, + depth + 1, + ) + } + _ => { + let Some(expected_ty) = + walk_to_type_shape(dag, expected, &SubstStack::new(), depth + 1) + else { + return false; + }; + let Some(actual_ty) = walk_to_type_shape(dag, actual.decl, &actual.subst, depth + 1) + else { + return false; + }; + expected_ty == actual_ty + } + } +} + +fn callable_instantiation_conflict( + dag: &Dag, + target: DeclarationId, + expected: DeclarationId, + actual: &PortTypeContext, + span: &SourceSpan, +) -> Diagnostic { + let expected_name = target_display_name(dag, expected); + let actual_name = resolve_binding_decl(dag, actual.decl, &actual.subst, 0) + .map(|id| target_display_name(dag, id)) + .unwrap_or_else(|| format!("declaration#{}", actual.decl.raw())); + Diagnostic::ResolveError { + name: format!( + "implicit template binding for `{}` conflicts while matching `{expected_name}` against `{actual_name}`", + target_display_name(dag, target) + ), + span: span.clone(), + correction: Correction::deferred_for_diagnostic_class("InferenceDiagnostic"), + } +} + +/// `port_type_context` reports the default `Int` type shape for integer literals, so +/// [`bind_expected_decl_to_actual_context`] can reject a callee that expects a range-backed +/// narrow type (`UInt8`, …). The main transform decision path already allows in-range +/// literal narrowing; mirror that here so [`resolve_callable_target`] can succeed. Out-of-range +/// literals surface as [`Diagnostic::MagnitudeOutOfRange`] to match +/// `decide(Transform)`-style call checks. +fn int_literal_implicit_bind_tolerated_for_expected( + dag: &Dag, + expected: DeclarationId, + input_port: PortId, + span: &SourceSpan, +) -> Result { + let Some(literal) = literal_bigint_at(dag, input_port) else { + return Ok(false); + }; + match int_literal_fits_expected_type(dag, &literal, expected) { + Ok(Some(true)) => Ok(true), + Ok(Some(false)) => match integer_range_for_decl(dag, expected) { + IntegerRangeLookup::Found(range) => Err(magnitude_out_of_range_for_interval( + &literal, + TypeShape::new(expected), + range, + span.clone(), + )), + IntegerRangeLookup::Invalid(diag) => Err(diag), + IntegerRangeLookup::Missing => Ok(false), + }, + Ok(None) => Ok(false), + Err(diag) => Err(diag), + } +} + +fn resolve_callable_target( + dag: &Dag, + target: DeclarationId, + runtime_inputs: &[PortId], + span: &SourceSpan, +) -> CallableTargetResolution { + let (template, mut arguments) = callable_template_arguments(dag, target); + arguments = retained_template_arguments_for_target(dag, template, &arguments); + let Some(signature) = resolve_direct_target_signature(dag, target, &arguments) else { + let name = target_display_name(dag, target); + return CallableTargetResolution::Fail(Diagnostic::ResolveError { + name, + span: span.clone(), + correction: Correction::deferred_for_diagnostic_class("InferenceDiagnostic"), + }); + }; + let mut raw_subst = SubstStack::new(); + raw_subst.push(arguments.clone()); + let raw_arrow_inputs = + resolve_arrow_decl_walk(dag, template, &mut raw_subst, 0).map(|arrow| arrow.inputs); + + let expected_runtime_arity = raw_arrow_inputs + .as_ref() + .map(|inputs| { + inputs + .iter() + .filter(|input| !declaration_is_callable(dag, **input, 0)) + .count() + }) + .unwrap_or_else(|| signature.inputs.len()); + if expected_runtime_arity != runtime_inputs.len() { + return CallableTargetResolution::Fail(Diagnostic::ArityMismatch { + function: target_display_name(dag, target), + expected: expected_runtime_arity, + actual: runtime_inputs.len(), + span: span.clone(), + correction: Correction::deferred_for_diagnostic_class("InferenceDiagnostic"), + }); + } + + let mut runtime_iter = runtime_inputs.iter(); + if let Some(raw_inputs) = raw_arrow_inputs { + for expected_input in raw_inputs { + if declaration_is_callable(dag, expected_input, 0) { + let Some(actual_callable) = template_argument_value(&arguments, expected_input) + else { + return CallableTargetResolution::Retry; + }; + match bind_expected_callable_to_actual( + dag, + expected_input, + actual_callable, + &mut arguments, + ) { + CallableBindingResolution::Resolved => {} + CallableBindingResolution::Retry => { + return CallableTargetResolution::Retry; + } + CallableBindingResolution::Conflict => { + return CallableTargetResolution::Fail( + Diagnostic::ResolveError { + name: format!( + "callable argument to `{}` does not match the expected function type", + target_display_name(dag, target) + ), + span: span.clone(), + correction: Correction::deferred_for_diagnostic_class("InferenceDiagnostic"), + }, + ); + } + } + continue; + } + let Some(input_port) = runtime_iter.next() else { + break; + }; + match dag.port(*input_port).state() { + PortState::Uninferred => return CallableTargetResolution::Retry, + PortState::Unresolved => { + return CallableTargetResolution::Fail(Diagnostic::ResolveError { + name: format!("(upstream failure in {})", target_display_name(dag, target)), + span: span.clone(), + correction: Correction::deferred_for_diagnostic_class( + "InferenceDiagnostic", + ), + }); + } + PortState::Resolved(_) => {} + } + let Some(actual_ctx) = port_type_context(dag, *input_port) else { + return CallableTargetResolution::Retry; + }; + if !bind_expected_decl_to_actual_context( + dag, + expected_input, + &actual_ctx, + &mut arguments, + 0, + ) { + match int_literal_implicit_bind_tolerated_for_expected( + dag, + expected_input, + *input_port, + span, + ) { + Ok(true) => {} + Ok(false) => { + return CallableTargetResolution::Fail(callable_instantiation_conflict( + dag, + target, + expected_input, + &actual_ctx, + span, + )); + } + Err(diag) => return CallableTargetResolution::Fail(diag), + } + } + } + } else { + for expected_input in &signature.inputs { + if declaration_is_callable(dag, expected_input.declaration, 0) { + let Some(actual_callable) = + template_argument_value(&arguments, expected_input.declaration) + else { + return CallableTargetResolution::Retry; + }; + match bind_expected_callable_to_actual( + dag, + expected_input.declaration, + actual_callable, + &mut arguments, + ) { + CallableBindingResolution::Resolved => {} + CallableBindingResolution::Retry => { + return CallableTargetResolution::Retry; + } + CallableBindingResolution::Conflict => { + return CallableTargetResolution::Fail(Diagnostic::ResolveError { + name: format!( + "callable argument to `{}` does not match the expected function type", + target_display_name(dag, target) + ), + span: span.clone(), + correction: Correction::deferred_for_diagnostic_class("InferenceDiagnostic"), + }); + } + } + continue; + } + let Some(actual_callable) = + template_argument_value(&arguments, expected_input.declaration) + else { + let Some(input_port) = runtime_iter.next() else { + break; + }; + match dag.port(*input_port).state() { + PortState::Uninferred => return CallableTargetResolution::Retry, + PortState::Unresolved => { + return CallableTargetResolution::Fail(Diagnostic::ResolveError { + name: format!( + "(upstream failure in {})", + target_display_name(dag, target) + ), + span: span.clone(), + correction: Correction::deferred_for_diagnostic_class( + "InferenceDiagnostic", + ), + }); + } + PortState::Resolved(_) => {} + } + let Some(actual_ctx) = port_type_context(dag, *input_port) else { + return CallableTargetResolution::Retry; + }; + if !bind_expected_decl_to_actual_context( + dag, + expected_input.declaration, + &actual_ctx, + &mut arguments, + 0, + ) { + match int_literal_implicit_bind_tolerated_for_expected( + dag, + expected_input.declaration, + *input_port, + span, + ) { + Ok(true) => {} + Ok(false) => { + return CallableTargetResolution::Fail( + callable_instantiation_conflict( + dag, + target, + expected_input.declaration, + &actual_ctx, + span, + ), + ); + } + Err(diag) => return CallableTargetResolution::Fail(diag), + } + } + continue; + }; + match bind_expected_callable_to_actual( + dag, + expected_input.declaration, + actual_callable, + &mut arguments, + ) { + CallableBindingResolution::Resolved => {} + CallableBindingResolution::Retry => { + return CallableTargetResolution::Retry; + } + CallableBindingResolution::Conflict => { + return CallableTargetResolution::Fail(Diagnostic::ResolveError { + name: format!( + "callable argument to `{}` does not match the expected function type", + target_display_name(dag, target) + ), + span: span.clone(), + correction: Correction::deferred_for_diagnostic_class( + "InferenceDiagnostic", + ), + }); + } + } + } + } + + let Some(signature) = resolve_direct_target_signature(dag, target, &arguments) else { + let name = target_display_name(dag, target); + return CallableTargetResolution::Fail(Diagnostic::ResolveError { + name, + span: span.clone(), + correction: Correction::deferred_for_diagnostic_class("InferenceDiagnostic"), + }); + }; + CallableTargetResolution::Resolved { + template, + arguments, + signature, + } +} + +fn resolve_direct_target_signature( + dag: &Dag, + target: DeclarationId, + arguments: &[TemplateArgument], +) -> Option { + let (template, _) = callable_template_arguments(dag, target); + let mut subst = SubstStack::new(); + subst.push(arguments.to_vec()); + if let Some(arrow) = resolve_arrow_walk(dag, template, &mut subst, 0) { + return Some(arrow); + } + + let TypeConnective::Conj { children } = &dag.declaration(template).connective else { + return None; + }; + let inputs = children + .iter() + .map(|child| signature_type_shape(dag, child.ty, &subst, 0)) + .collect::>>()?; + Some(ResolvedArrow { + inputs, + output: TypeShape::new( + instantiated_enclosing_disj_for_variant(dag, template, arguments) + .or_else(|| enclosing_disj_for_variant(dag, template)) + .unwrap_or(target), + ), + // Variant constructor synthesis: `Variant(payload)` is direct + // construction, not a function call with an executable body. + // `NoBody` rather than `Pending` so the synthesized signature + // semantically matches the "no body by construction" cases in + // declarations, even though this `ResolvedArrow` is transient + // inference state (never stored in `Dag.declarations`). + body: ArrowBody::NoBody, + }) +} + +fn instantiated_enclosing_disj_for_variant( + dag: &Dag, + variant_decl_id: DeclarationId, + arguments: &[TemplateArgument], +) -> Option { + let enclosing_disj = enclosing_disj_for_variant(dag, variant_decl_id)?; + let params = &dag.declaration(enclosing_disj).type_params; + if params.is_empty() { + return Some(enclosing_disj); + } + let disj_arguments = params + .iter() + .map(|parameter| { + arguments + .iter() + .find(|argument| argument.parameter == *parameter) + .cloned() + }) + .collect::>>()?; + find_equivalent_anonymous_instantiation(dag, enclosing_disj, &disj_arguments, None) +} + +fn resolve_callable_targets(dag: &mut Dag) -> bool { + struct Rewrite { + node_index: usize, + template: DeclarationId, + arguments: Vec, + } + + let mut rewrites: Vec = Vec::new(); + for (node_index, node) in dag.nodes().iter().enumerate() { + let Behavior::Transform(t) = node else { + continue; + }; + let TransformTarget::Callable(target) = t.target else { + continue; + }; + let CallableTargetResolution::Resolved { + template, + arguments, + .. + } = resolve_callable_target(dag, target, &t.inputs, &t.span) + else { + continue; + }; + let (current_template, current_arguments) = callable_template_arguments(dag, target); + if current_template == template + && matches!( + generated_template_arguments_match(¤t_arguments, &arguments), + TemplateArgumentsMatch::Match, + ) + { + continue; + } + if arguments.is_empty() && current_template == template { + continue; + } + rewrites.push(Rewrite { + node_index, + template, + arguments, + }); + } + + let mut changed = false; + for rewrite in rewrites { + let new_target = if rewrite.arguments.is_empty() { + rewrite.template + } else { + let instantiation_id = dag.alloc_declaration_id(); + dag.push_declaration(crate::dag::Declaration { + id: instantiation_id, + name: None, + connective: TypeConnective::Instantiation { + template: rewrite.template, + arguments: rewrite.arguments, + }, + type_params: Vec::new(), + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: None, + span: synthetic_span(), + }); + instantiation_id + }; + let Behavior::Transform(t) = &mut dag.nodes_mut()[rewrite.node_index] else { + continue; + }; + let TransformTarget::Callable(target) = &mut t.target else { + continue; + }; + if *target != new_target { + *target = new_target; + changed = true; + } + } + changed +} + +fn materialize_callable_signature_instantiations(dag: &mut Dag) -> bool { + let requests: Vec<(DeclarationId, Vec)> = dag + .nodes() + .iter() + .filter_map(|node| { + let Behavior::Transform(t) = node else { + return None; + }; + let TransformTarget::Callable(target) = t.target else { + return None; + }; + let (template, arguments) = callable_template_arguments(dag, target); + (!arguments.is_empty()).then_some((template, arguments)) + }) + .collect(); + + let initial_count = dag.declarations().len(); + for (template, arguments) in requests { + let mut subst = SubstStack::new(); + subst.push(arguments); + let Some(raw_arrow) = resolve_arrow_decl_walk(dag, template, &mut subst, 0) else { + continue; + }; + for input in raw_arrow.inputs { + let _ = concretize_decl_with_subst(dag, input, &subst, 0); + } + let _ = concretize_decl_with_subst(dag, raw_arrow.output, &subst, 0); + } + dag.declarations().len() != initial_count +} + +fn resolve_lambda_parameter_types(dag: &mut Dag) -> bool { + struct Rewrite { + port: PortId, + ty: TypeShape, + } + + let mut rewrites: Vec = Vec::new(); + + for node in dag.nodes() { + let Behavior::Transform(t) = node else { + continue; + }; + let TransformTarget::Callable(target) = t.target else { + continue; + }; + let (template, arguments) = match resolve_callable_target(dag, target, &t.inputs, &t.span) { + CallableTargetResolution::Resolved { + template, + arguments, + .. + } => (template, arguments), + CallableTargetResolution::Retry => { + bind_non_callable_target_arguments(dag, target, &t.inputs) + .unwrap_or_else(|| callable_template_arguments(dag, target)) + } + CallableTargetResolution::Fail(_) => continue, + }; + let mut outer_subst = SubstStack::new(); + outer_subst.push(arguments.clone()); + let Some(raw_arrow) = resolve_arrow_decl_walk(dag, template, &mut SubstStack::new(), 0) + else { + continue; + }; + for expected_input in raw_arrow.inputs { + if !declaration_is_callable(dag, expected_input, 0) { + continue; + } + let Some(actual_callable) = template_argument_value(&arguments, expected_input) else { + continue; + }; + let Some(expected_signature) = + resolve_arrow_walk(dag, expected_input, &mut outer_subst.clone(), 0) + else { + continue; + }; + let TypeConnective::Arrow { body, .. } = &dag.declaration(actual_callable).connective + else { + continue; + }; + let ArrowBody::UserDefined(bind_id) = body else { + continue; + }; + let bind = (*bind_id).bind(dag); + if bind.params.len() < expected_signature.inputs.len() { + continue; + } + let start = bind.params.len() - expected_signature.inputs.len(); + for (port, ty) in bind.params[start..] + .iter() + .copied() + .zip(expected_signature.inputs.iter().copied()) + { + rewrites.push(Rewrite { port, ty }); + } + } + } + + let mut changed = false; + for rewrite in rewrites { + let current = dag.port(rewrite.port).state().clone(); + if matches!(current, PortState::Resolved(existing) if existing == rewrite.ty) { + continue; + } + if matches!(current, PortState::Unresolved) { + continue; + } + dag.set_port_type(rewrite.port, rewrite.ty); + changed = true; + } + changed +} + +fn validate_user_defined_function_signatures(dag: &mut Dag) -> bool { + struct Failure { + port: PortId, + diagnostic: Diagnostic, + } + + let user_defined_arrows: Vec<(DeclarationId, Vec, DeclarationId, BindNodeId)> = + dag.declarations() + .iter() + .filter_map(|decl| match &decl.connective { + TypeConnective::Arrow { + inputs, + output, + body: ArrowBody::UserDefined(bind_id), + } => Some((decl.id, inputs.clone(), *output, *bind_id)), + _ => None, + }) + .collect(); + + let mut failures = Vec::new(); + + 'declarations: for (decl_id, inputs, output, bind_id) in user_defined_arrows { + let bind = bind_id.bind(dag); + if matches!(dag.port(bind.value).state(), PortState::Unresolved) { + continue; + } + if bind.params.len() < inputs.len() { + failures.push(Failure { + port: bind.value, + diagnostic: Diagnostic::ResolveError { + name: format!( + "function `{}` body does not satisfy its declared signature", + dag.declaration(decl_id) + .name + .as_deref() + .unwrap_or("") + ), + span: bind.span.clone(), + correction: witness_correction_for_decl( + dag, + output, + bind.span.clone(), + format!( + "replace the function body with a `{}` value", + declaration_display_name(dag, output) + ), + ) + .unwrap_or_else(|| { + crate::diagnostics::Correction::deferred_for_diagnostic_class( + "InferenceDiagnostic", + ) + }), + }, + }); + continue; + } + + let start = bind.params.len() - inputs.len(); + let mut arguments = Vec::new(); + for (index, (port, expected_decl)) in bind.params[start..] + .iter() + .copied() + .zip(inputs.iter().copied()) + .enumerate() + { + if declaration_is_callable(dag, expected_decl, 0) { + continue; + } + match dag.port(port).state() { + PortState::Uninferred | PortState::Unresolved => continue 'declarations, + PortState::Resolved(_) => {} + } + let Some(actual_ctx) = port_type_context(dag, port) else { + continue 'declarations; + }; + if bind_expected_decl_to_actual_context( + dag, + expected_decl, + &actual_ctx, + &mut arguments, + 0, + ) { + continue; + } + + failures.push(Failure { + port: bind.value, + diagnostic: Diagnostic::ResolveError { + name: format!( + "function `{}` parameter {} does not satisfy its declared signature", + dag.declaration(decl_id) + .name + .as_deref() + .unwrap_or(""), + index + 1 + ), + span: bind.span.clone(), + correction: witness_correction_for_decl( + dag, + expected_decl, + bind.span.clone(), + format!( + "replace the parameter use with a `{}` value", + declaration_display_name(dag, expected_decl) + ), + ) + .unwrap_or_else(|| { + crate::diagnostics::Correction::deferred_for_diagnostic_class( + "InferenceDiagnostic", + ) + }), + }, + }); + continue 'declarations; + } + + match dag.port(bind.value).state() { + PortState::Uninferred | PortState::Unresolved => continue, + PortState::Resolved(_) => {} + } + if declaration_is_callable(dag, output, 0) { + continue; + } + let Some(actual_ctx) = port_type_context(dag, bind.value) else { + continue; + }; + if bind_expected_decl_to_actual_context(dag, output, &actual_ctx, &mut arguments, 0) { + continue; + } + + failures.push(Failure { + port: bind.value, + diagnostic: Diagnostic::ResolveError { + name: format!( + "function `{}` body does not satisfy its declared return type", + dag.declaration(decl_id) + .name + .as_deref() + .unwrap_or("") + ), + span: bind.span.clone(), + correction: witness_correction_for_decl( + dag, + output, + bind.span.clone(), + format!( + "replace the function body with a `{}` value", + declaration_display_name(dag, output) + ), + ) + .unwrap_or_else(|| { + crate::diagnostics::Correction::deferred_for_diagnostic_class( + "InferenceDiagnostic", + ) + }), + }, + }); + } + + let mut changed = false; + for failure in failures { + if matches!(dag.port(failure.port).state(), PortState::Unresolved) { + continue; + } + dag.mark_unresolved(failure.port, failure.diagnostic); + changed = true; + } + changed +} + +fn bind_non_callable_target_arguments( + dag: &Dag, + target: DeclarationId, + runtime_inputs: &[PortId], +) -> Option<(DeclarationId, Vec)> { + let (template, mut arguments) = callable_template_arguments(dag, target); + let signature = resolve_direct_target_signature(dag, target, &arguments)?; + let mut raw_subst = SubstStack::new(); + raw_subst.push(arguments.clone()); + let raw_arrow_inputs = + resolve_arrow_decl_walk(dag, template, &mut raw_subst, 0).map(|arrow| arrow.inputs); + + let expected_runtime_arity = raw_arrow_inputs + .as_ref() + .map(|inputs| { + inputs + .iter() + .filter(|input| !declaration_is_callable(dag, **input, 0)) + .count() + }) + .unwrap_or_else(|| signature.inputs.len()); + if expected_runtime_arity != runtime_inputs.len() { + return None; + } + + let mut runtime_iter = runtime_inputs.iter(); + if let Some(raw_inputs) = raw_arrow_inputs { + for expected_input in raw_inputs { + if declaration_is_callable(dag, expected_input, 0) { + continue; + } + let input_port = *runtime_iter.next()?; + let actual_ctx = port_type_context(dag, input_port)?; + if !bind_expected_decl_to_actual_context( + dag, + expected_input, + &actual_ctx, + &mut arguments, + 0, + ) { + return None; + } + } + } else { + for expected_input in &signature.inputs { + if declaration_is_callable(dag, expected_input.declaration, 0) { + continue; + } + let input_port = *runtime_iter.next()?; + let actual_ctx = port_type_context(dag, input_port)?; + if !bind_expected_decl_to_actual_context( + dag, + expected_input.declaration, + &actual_ctx, + &mut arguments, + 0, + ) { + return None; + } + } + } + + Some((template, arguments)) +} + +fn walk_to_conj_decl_with_subst( + dag: &Dag, + start: DeclarationId, + subst: &mut SubstStack, +) -> Option { + match walk_to_conj_decl_with_subst_or_opacity(dag, start, subst, false) { + ConjWalkResult::Conj(id) => Some(id), + ConjWalkResult::NoConj => None, + ConjWalkResult::NominalOpaque(_) => { + unreachable!("opacity check is disabled for ordinary Conj walks") + } + } +} + +enum ConjWalkResult { + Conj(DeclarationId), + NominalOpaque(DeclarationId), + NoConj, +} + +fn walk_to_conj_decl_with_subst_or_opacity( + dag: &Dag, + start: DeclarationId, + subst: &mut SubstStack, + stop_at_nominal_opacity: bool, +) -> ConjWalkResult { + let mut current = start; + for _ in 0..WALK_DEPTH_LIMIT { + let decl = dag.declaration(current); + if stop_at_nominal_opacity && decl.nominal_opacity.is_some() { + return ConjWalkResult::NominalOpaque(current); + } + match &decl.connective { + TypeConnective::Conj { .. } => return ConjWalkResult::Conj(current), + TypeConnective::Instantiation { + template, + arguments, + } => { + subst.push(arguments.clone()); + current = *template; + } + TypeConnective::Atom(AtomPayload::ResolvedByStructure(next)) + | TypeConnective::Atom(AtomPayload::ResolvedByName(next)) => { + current = *next; + } + TypeConnective::Atom(AtomPayload::TypeParam(_)) => { + let Some(next) = subst.lookup(current) else { + return ConjWalkResult::NoConj; + }; + current = next; + } + _ => return ConjWalkResult::NoConj, + } + } + ConjWalkResult::NoConj +} + +fn walk_to_disj_decl_with_subst( + dag: &Dag, + start: DeclarationId, + subst: &mut SubstStack, +) -> Option { + let mut current = start; + for _ in 0..WALK_DEPTH_LIMIT { + let decl = dag.declaration(current); + match &decl.connective { + TypeConnective::Disj { .. } => return Some(current), + TypeConnective::Cardinality(p) + if p.bound() == crate::dag::CardinalityBound::AtMostOne => + { + return existing_optional_match_disj_decl(dag, current); + } + TypeConnective::Instantiation { + template, + arguments, + } => { + subst.push(arguments.clone()); + current = *template; + } + TypeConnective::Atom(AtomPayload::ResolvedByStructure(next)) + | TypeConnective::Atom(AtomPayload::ResolvedByName(next)) => { + current = *next; + } + TypeConnective::Atom(AtomPayload::TypeParam(_)) => { + current = subst.lookup(current)?; + } + _ => return None, + } + } + None +} + +fn enclosing_disj_for_variant(dag: &Dag, variant_decl_id: DeclarationId) -> Option { + dag.declarations().iter().find_map(|decl| { + let TypeConnective::Disj { variants } = &decl.connective else { + return None; + }; + variants + .iter() + .find(|variant| variant.ty == variant_decl_id) + .map(|_| decl.id) + }) +} + +fn resolve_payload_binding_type( + dag: &Dag, + variant_decl_id: DeclarationId, + subst: &SubstStack, + variant_name: &str, + binding_name: &str, + span: &SourceSpan, +) -> Result { + match &dag.declaration(variant_decl_id).connective { + TypeConnective::Conj { children } if children.is_empty() => { + Err(Diagnostic::ResolveError { + name: format!( + "variant `{variant_name}` does not carry a payload and cannot bind `{binding_name}`" + ), + span: span.clone(), + correction: Correction::deferred_for_diagnostic_class("InferenceDiagnostic"), + }) + } + TypeConnective::Conj { children } + if children.len() == 1 && children[0].label.as_str() == "_0" => + { + let payload_type_decl = children[0].ty; + walk_to_type_shape(dag, payload_type_decl, subst, 0) + .map(PayloadBindingResolution::Direct) + .ok_or_else(|| Diagnostic::ResolveError { + name: format!( + "variant `{variant_name}` payload does not resolve to a port type for binding `{binding_name}`" + ), + span: span.clone(), + correction: Correction::deferred_for_diagnostic_class("InferenceDiagnostic"), + }) + } + TypeConnective::Conj { .. } => Ok(PayloadBindingResolution::SpecializedRecord { + variant_decl_id, + subst: subst.clone(), + }), + _ => { + Err(Diagnostic::ResolveError { + name: format!( + "variant `{variant_name}` does not lower to a payload Conj and cannot bind `{binding_name}`" + ), + span: span.clone(), + correction: Correction::deferred_for_diagnostic_class("InferenceDiagnostic"), + }) + } + } +} + +fn materialize_specialized_payload_record( + dag: &mut Dag, + variant_decl_id: DeclarationId, + subst: &SubstStack, +) -> TypeShape { + let variant_decl = dag.declaration(variant_decl_id).clone(); + let TypeConnective::Conj { children } = variant_decl.connective else { + return TypeShape::new(variant_decl_id); + }; + let specialized_children: Vec = children + .into_iter() + .map(|field| Field { + label: field.label, + ty: concretize_decl_with_subst(dag, field.ty, subst, 0), + }) + .collect(); + if let Some(existing) = find_equivalent_anonymous_conj(dag, &specialized_children) { + return TypeShape::new(existing); + } + let id = dag.alloc_declaration_id(); + dag.push_declaration(Declaration { + id, + name: None, + connective: TypeConnective::Conj { + children: specialized_children, + }, + type_params: Vec::new(), + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: None, + span: variant_decl.span, + }); + TypeShape::new(id) +} + +pub(crate) fn concretize_decl_with_subst( + dag: &mut Dag, + current: DeclarationId, + subst: &SubstStack, + depth: usize, +) -> DeclarationId { + if depth >= WALK_DEPTH_LIMIT { + return current; + } + let decl = dag.declaration(current).clone(); + // DB-16 (3a.3 closure): refinement-bearing declarations whose base + // requires substitution materialize a fresh substituted-refined + // carrier — structurally identical to what `lower_parameter_refinement` + // would produce if the user had authored the refinement on the + // concrete type directly. Runs before the connective match so the + // refinement edge is preserved across the walk. + if decl.refinement.is_some() && refinement_base_requires_substitution(dag, current, subst) { + return materialize_substituted_refined_decl(dag, current, subst); + } + match decl.connective { + TypeConnective::Atom(AtomPayload::TypeParam(_)) => subst.lookup(current).unwrap_or(current), + TypeConnective::Atom(AtomPayload::ResolvedByStructure(next)) + | TypeConnective::Atom(AtomPayload::ResolvedByName(next)) => { + concretize_decl_with_subst(dag, next, subst, depth + 1) + } + TypeConnective::Instantiation { + template, + arguments, + } => { + let specialized_arguments: Vec = arguments + .into_iter() + .map(|arg| TemplateArgument { + parameter: arg.parameter, + value: concretize_decl_with_subst(dag, arg.value, subst, depth + 1), + }) + .collect(); + if let Some(existing) = find_equivalent_anonymous_instantiation( + dag, + template, + &specialized_arguments, + decl.nominal_opacity.as_ref(), + ) { + return existing; + } + let id = dag.alloc_declaration_id(); + dag.push_declaration(Declaration { + id, + name: None, + connective: TypeConnective::Instantiation { + template, + arguments: specialized_arguments, + }, + type_params: Vec::new(), + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: decl.nominal_opacity.clone(), + span: decl.span, + }); + id + } + TypeConnective::Cardinality(p) => { + let element = p.element(); + let bound = p.bound(); + let specialized_element = concretize_decl_with_subst(dag, element, subst, depth + 1); + if let Some(existing) = + find_equivalent_anonymous_cardinality(dag, specialized_element, &bound) + { + return existing; + } + dag.alloc_cardinality_decl(specialized_element, bound, decl.span.clone()) + } + _ => current, + } +} + +/// DB-16 (3a.3 closure): gate used by both `concretize_decl_with_subst` +/// (construction side) and `signature_type_shape` (consumer side) to +/// decide whether a refinement-bearing declaration needs substitution +/// before its refinement edge can be meaningfully consumed. +/// +/// Returns `true` iff walking the refined carrier's base through +/// `Atom(ResolvedIdentifier(_))` hops lands on either: +/// - a `TypeParam` declaration whose id is bound in `subst`, or +/// - an `Instantiation` whose arguments reference substitution-bound +/// TypeParams (i.e., substitution would materially change the +/// resolved base). +/// +/// For concrete refined carriers (`Int where pred(x)`) the walk +/// short-circuits to `false`; DB-11's identity-terminator fires +/// unchanged. For refined TypeParams with no active binding (earlier +/// iteration retry case) the walk also returns `false`. +fn refinement_base_requires_substitution( + dag: &Dag, + current: DeclarationId, + subst: &SubstStack, +) -> bool { + let decl = dag.declaration(current); + let TypeConnective::Atom( + AtomPayload::ResolvedByStructure(base) | AtomPayload::ResolvedByName(base), + ) = &decl.connective + else { + return false; + }; + refinement_base_walk(dag, *base, subst, 0) +} + +fn refinement_base_walk( + dag: &Dag, + current: DeclarationId, + subst: &SubstStack, + depth: usize, +) -> bool { + if depth >= WALK_DEPTH_LIMIT { + return false; + } + let decl = dag.declaration(current); + match &decl.connective { + TypeConnective::Atom(AtomPayload::TypeParam(_)) => subst.lookup(current).is_some(), + TypeConnective::Atom(AtomPayload::ResolvedByStructure(next)) + | TypeConnective::Atom(AtomPayload::ResolvedByName(next)) => { + refinement_base_walk(dag, *next, subst, depth + 1) + } + TypeConnective::Instantiation { arguments, .. } => arguments + .iter() + .any(|arg| refinement_base_walk(dag, arg.value, subst, depth + 1)), + TypeConnective::Cardinality(p) => refinement_base_walk(dag, p.element(), subst, depth + 1), + _ => false, + } +} + +/// DB-16 (3a.3 closure): the sole construction site for substituted +/// refined carriers. Called from `concretize_decl_with_subst`'s +/// refinement branch during the `materialize_callable_signature_instantiations` +/// phase. Produces a fresh anonymous `Declaration` structurally +/// identical to what `lower_parameter_refinement` would produce if +/// the user had authored the refinement on the concrete substituted +/// base directly. +/// +/// Dedups against existing substituted-refined carriers in the Dag +/// before allocating — at fixpoint iteration N+1, if iteration N +/// already produced a carrier for this (template, subst), the scan +/// finds it and no new allocation happens. +/// +/// Fail-closed per C-8: substrate-integrity violations register +/// diagnostics rather than silently degrading to the template carrier. +fn materialize_substituted_refined_decl( + dag: &mut Dag, + template_refined: DeclarationId, + subst: &SubstStack, +) -> DeclarationId { + // Dedup: if a matching substituted-refined carrier already exists + // (e.g., from an earlier fixpoint iteration), reuse it. + if let Some(existing) = find_equivalent_substituted_refined_decl(dag, template_refined, subst) { + return existing; + } + + let template_decl = dag.declaration(template_refined).clone(); + let Some(template_pred_decl_id) = template_decl.refinement else { + // Caller (concretize_decl_with_subst's refinement branch) checks + // `decl.refinement.is_some()` before entering. Reaching this + // point means the caller contract was violated — surface that + // rather than masking it with a silent fallthrough. + unreachable!( + "materialize_substituted_refined_decl entered on declaration {:?} without refinement edge", + template_refined + ); + }; + let TypeConnective::Atom( + AtomPayload::ResolvedByStructure(template_base) + | AtomPayload::ResolvedByName(template_base), + ) = template_decl.connective + else { + // Caller also checks `refinement_base_requires_substitution`, + // whose first step returns false for any connective other than + // `Atom(ResolvedIdentifier(_))`. Reaching this point means the + // caller contract was violated. + unreachable!( + "materialize_substituted_refined_decl entered on declaration {:?} whose connective is not Atom(ResolvedIdentifier(_))", + template_refined + ); + }; + let template_span = template_decl.span.clone(); + + // Step 1: resolve the substituted base. + let Some(substituted_base) = resolve_decl_with_subst(dag, template_base, subst, 0) else { + dag.attach_diagnostic(Diagnostic::ResolveError { + name: "refined-generic substitution: substituted base did not resolve".to_string(), + span: template_span.clone(), + correction: Correction::deferred_for_diagnostic_class("InferenceDiagnostic"), + }); + return template_refined; + }; + + // Step 2: extract original predicate slots. + let Some((original_param_port, original_body_port)) = + outer_predicate_slots(dag, template_pred_decl_id) + else { + let pred_span = dag.declaration(template_pred_decl_id).span.clone(); + dag.attach_diagnostic(Diagnostic::ResolveError { + name: "refined-generic substitution: malformed predicate shape".to_string(), + span: pred_span, + correction: Correction::deferred_for_diagnostic_class("InferenceDiagnostic"), + }); + return template_refined; + }; + + // Step 3: allocate fresh composite param port typed as substituted base. + let fresh_param_port = dag.alloc_port(None); + dag.set_port_type(fresh_param_port, TypeShape::new(substituted_base)); + + // Step 4: clone the predicate body, routing Transform targets + // through the active substitution stack. + let Some(cloned_body_port) = clone_predicate_body( + dag, + original_body_port, + original_param_port, + fresh_param_port, + subst, + 0, + ) else { + dag.attach_diagnostic(Diagnostic::ResolveError { + name: "refined-generic substitution: out-of-fragment predicate body reached materialization" + .to_string(), + span: template_span.clone(), + correction: Correction::deferred_for_diagnostic_class("InferenceDiagnostic"), + }); + return template_refined; + }; + + // Step 5: wrap cloned body in a fresh Bind. + let bind_id = dag.alloc_node_id(); + dag.push_node(Behavior::Bind(BindNode { + id: bind_id, + name: "".to_string(), + value: cloned_body_port, + params: vec![fresh_param_port], + span: template_span.clone(), + lane2_workflow: None, + emit_participation: None, + })); + + // Step 5 (cont.): build the fresh predicate-Arrow Declaration. + // Inherit the original predicate's output type (Bool) — reading it + // from the original Arrow keeps this module independent of the + // Bool-decl lookup pattern `lower_parameter_refinement` uses. + let TypeConnective::Arrow { + output: bool_decl_id, + .. + } = &dag.declaration(template_pred_decl_id).connective + else { + // `outer_predicate_slots` rejected non-Arrow predicates at + // step 2 above; reaching this point means the predicate + // declaration's connective mutated between step 2 and step 5, + // which is impossible under `&mut Dag` exclusive access. + unreachable!( + "predicate declaration {:?} connective is not Arrow despite outer_predicate_slots succeeding", + template_pred_decl_id + ); + }; + let bool_decl_id = *bool_decl_id; + + let fresh_pred_decl_id = dag.alloc_declaration_id(); + dag.push_declaration(Declaration { + id: fresh_pred_decl_id, + name: None, + connective: TypeConnective::Arrow { + inputs: vec![substituted_base], + output: bool_decl_id, + body: ArrowBody::UserDefined( + BindNodeId::from_bind_node(dag, bind_id) + .expect("UserDefined Arrow body bind id must point at a Bind"), + ), + }, + type_params: Vec::new(), + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: None, + span: template_span.clone(), + }); + + // Step 6: allocate the substituted-refined carrier Declaration. + let fresh_refined_id = dag.alloc_declaration_id(); + dag.push_declaration(Declaration { + id: fresh_refined_id, + name: None, + connective: TypeConnective::Atom(AtomPayload::ResolvedByStructure(substituted_base)), + type_params: Vec::new(), + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: Some(fresh_pred_decl_id), + nominal_opacity: None, + span: template_span, + }); + + fresh_refined_id +} + +/// DB-16 (3a.3 closure): read-only lookup used by both +/// `concretize_decl_with_subst` (pre-allocation dedup) and +/// `signature_type_shape` (consumer-side lookup gate). +/// +/// Given a template refined carrier and an active substitution, scans +/// `dag.declarations()` for an already-materialized substituted-refined +/// carrier whose base matches the substituted base AND whose predicate +/// body walks structurally equal to what cloning the template's body +/// with the given subst WOULD produce. Mirrors +/// `find_equivalent_anonymous_instantiation`'s dedup pattern. +/// +/// Pure read; no allocation. `&Dag`. +fn find_equivalent_substituted_refined_decl( + dag: &Dag, + template_refined: DeclarationId, + subst: &SubstStack, +) -> Option { + let template_decl = dag.declaration(template_refined); + let TypeConnective::Atom( + AtomPayload::ResolvedByStructure(template_base) + | AtomPayload::ResolvedByName(template_base), + ) = &template_decl.connective + else { + return None; + }; + let template_pred_id = template_decl.refinement?; + + let substituted_base = resolve_decl_with_subst(dag, *template_base, subst, 0)?; + if substituted_base == *template_base { + return None; + } + + let (template_param, template_body) = outer_predicate_slots(dag, template_pred_id)?; + + for decl in dag.declarations() { + if decl.name.is_some() { + continue; + } + if decl.id == template_refined { + continue; + } + let TypeConnective::Atom( + AtomPayload::ResolvedByStructure(cand_base) | AtomPayload::ResolvedByName(cand_base), + ) = &decl.connective + else { + continue; + }; + if *cand_base != substituted_base { + continue; + } + let Some(cand_pred_id) = decl.refinement else { + continue; + }; + let Some((cand_param, cand_body)) = outer_predicate_slots(dag, cand_pred_id) else { + continue; + }; + if predicate_bodies_equal_under_subst( + dag, + cand_body, + cand_param, + template_body, + template_param, + subst, + 0, + ) { + return Some(decl.id); + } + } + None +} + +/// DB-16 (3a.3 closure): lockstep structural equality on two predicate +/// bodies, threading the active substitution stack through the +/// template side's Transform target decl-level references. Used by +/// `find_equivalent_substituted_refined_decl` to compare a candidate +/// materialized body against what cloning the template would produce. +/// +/// Pure read; walks Value/Transform nodes, treats the parameter-slot +/// on each side as equivalent. Mirrors `clone_predicate_body`'s shape +/// exactly so the comparison is faithful to what cloning emits. +fn predicate_bodies_equal_under_subst( + dag: &Dag, + cand_port: PortId, + cand_param: PortId, + template_port: PortId, + template_param: PortId, + subst: &SubstStack, + depth: usize, +) -> bool { + if depth >= WALK_DEPTH_LIMIT { + return false; + } + let cand_is_param = cand_port == cand_param; + let template_is_param = template_port == template_param; + if cand_is_param != template_is_param { + return false; + } + if cand_is_param { + return true; + } + + let cand_node_id = dag.port(cand_port).produced_by; + let template_node_id = dag.port(template_port).produced_by; + match (cand_node_id, template_node_id) { + (None, None) => cand_port == template_port, + (Some(cand_id), Some(template_id)) => match (dag.node(cand_id), dag.node(template_id)) { + (Behavior::Value(cand_v), Behavior::Value(template_v)) => { + cand_v.data == template_v.data + } + (Behavior::Transform(cand_t), Behavior::Transform(template_t)) => { + if !transform_targets_equal_under_subst( + dag, + &cand_t.target, + &template_t.target, + subst, + ) { + return false; + } + if cand_t.inputs.len() != template_t.inputs.len() { + return false; + } + cand_t + .inputs + .iter() + .zip(template_t.inputs.iter()) + .all(|(c, t)| { + predicate_bodies_equal_under_subst( + dag, + *c, + cand_param, + *t, + template_param, + subst, + depth + 1, + ) + }) + } + _ => false, + }, + _ => false, + } +} + +fn transform_targets_equal_under_subst( + dag: &Dag, + cand: &TransformTarget, + template: &TransformTarget, + subst: &SubstStack, +) -> bool { + match (cand, template) { + (TransformTarget::Operator(a), TransformTarget::Operator(b)) => a == b, + (TransformTarget::Callable(cand_id), TransformTarget::Callable(template_id)) => { + callable_decls_equal_under_subst(dag, *cand_id, *template_id, subst) + } + ( + TransformTarget::UnresolvedFieldProject { + field_label: cand_label, + }, + TransformTarget::UnresolvedFieldProject { + field_label: template_label, + }, + ) => cand_label == template_label, + ( + TransformTarget::ResolvedFieldProject { + field_label: cand_label, + }, + TransformTarget::ResolvedFieldProject { + field_label: template_label, + }, + ) => cand_label == template_label, + _ => false, + } +} + +/// DB-16 (3a.3 closure): structural equivalence between two declaration +/// references under an active substitution stack. Used by +/// `transform_targets_equal_under_subst` to compare Callable / +/// FieldProject target decl-level references when one side is a +/// template-context Instantiation (possibly carrying extra bindings +/// for outer TypeParams) and the other is a concrete site +/// Instantiation (only bindings for the callee's own TypeParams). +/// +/// Strategy: normalize both sides to their template-own-type-param +/// bindings (filtering out substitution-stack artifacts from the +/// template side), resolve through the subst stack on the template +/// side, then compare. Falls back to +/// `declaration_shapes_equivalent` when either side isn't an +/// Instantiation (direct decl id match). +fn callable_decls_equal_under_subst( + dag: &Dag, + cand_id: DeclarationId, + template_id: DeclarationId, + subst: &SubstStack, +) -> bool { + if cand_id == template_id { + return true; + } + // Fast path: resolve template through subst and compare directly. + let template_resolved = + resolve_decl_with_subst(dag, template_id, subst, 0).unwrap_or(template_id); + if cand_id == template_resolved + || declaration_shapes_equivalent(dag, cand_id, template_resolved, 0) + { + return true; + } + // Fallback: both are Instantiations, compare by template own-type-params. + let (Some(cand_norm), Some(template_norm)) = ( + normalized_instantiation_args(dag, cand_id), + normalized_instantiation_args(dag, template_id), + ) else { + return false; + }; + if cand_norm.template != template_norm.template { + return false; + } + if cand_norm.args.len() != template_norm.args.len() { + return false; + } + cand_norm + .args + .iter() + .zip(template_norm.args.iter()) + .all(|(c_arg, t_arg)| { + c_arg.parameter == t_arg.parameter && { + let t_val = + resolve_decl_with_subst(dag, t_arg.value, subst, 0).unwrap_or(t_arg.value); + c_arg.value == t_val || declaration_shapes_equivalent(dag, c_arg.value, t_val, 0) + } + }) +} + +struct NormalizedInstantiation { + template: DeclarationId, + args: Vec, +} + +/// DB-16 (3a.3 closure): normalize an `Instantiation` declaration by +/// stripping **only** self-bindings (`arg.parameter == arg.value`). +/// +/// Self-bindings are reattachment artifacts produced by +/// `resolve_callable_target`'s unification when a generic call site +/// is encountered under an outer generic scope: the outer TypeParam +/// binds to itself in the callee's argument list because no concrete +/// value has been inferred yet. Those self-bindings are no-op under +/// substitution (`SubstStack::lookup` short-circuits to `None` on +/// them) but inflate argument-length comparisons in strict +/// structural checks. +/// +/// Non-self bindings are NEVER stripped: retained callable-argument +/// identities (per `retained_template_arguments_for_target`) carry +/// semantic meaning that must survive DB-16's equivalence walk. Two +/// Instantiations that differ only by a non-self retained binding +/// are structurally distinct and must compare unequal. +fn normalized_instantiation_args( + dag: &Dag, + decl: DeclarationId, +) -> Option { + match normalize_instantiation_arguments(&dag.declaration(decl).connective) { + NormalizedInstantiationArgs::NotInstantiation => None, + NormalizedInstantiationArgs::Normalized { template, args } => { + Some(NormalizedInstantiation { template, args }) + } + } +} + +fn find_equivalent_anonymous_conj(dag: &Dag, children: &[Field]) -> Option { + dag.declarations().iter().find_map(|decl| { + if decl.name.is_some() { + return None; + } + let TypeConnective::Conj { children: existing } = &decl.connective else { + return None; + }; + (existing.len() == children.len() + && existing + .iter() + .zip(children.iter()) + .all(|(lhs, rhs)| lhs.label == rhs.label && lhs.ty == rhs.ty)) + .then_some(decl.id) + }) +} + +fn find_equivalent_anonymous_instantiation( + dag: &Dag, + template: DeclarationId, + arguments: &[TemplateArgument], + nominal_opacity: Option<&NominalOpacity>, +) -> Option { + dag.declarations().iter().find_map(|decl| { + if decl.name.is_some() { + return None; + } + let TypeConnective::Instantiation { + template: existing_template, + arguments: existing_arguments, + } = &decl.connective + else { + return None; + }; + (template == *existing_template + && decl.nominal_opacity.as_ref() == nominal_opacity + && matches!( + generated_template_arguments_match(existing_arguments, arguments), + TemplateArgumentsMatch::Match, + )) + .then_some(decl.id) + }) +} + +fn find_equivalent_anonymous_cardinality( + dag: &Dag, + element: DeclarationId, + bound: &crate::dag::CardinalityBound, +) -> Option { + dag.declarations().iter().find_map(|decl| { + if decl.name.is_some() { + return None; + } + let TypeConnective::Cardinality(existing) = &decl.connective else { + return None; + }; + (element == existing.element() && existing.bound() == *bound).then_some(decl.id) + }) +} + +enum FieldProjectResolution { + Retry, + Fail(Box), + Resolved { output_ty: TypeShape }, +} + +impl FieldProjectResolution { + fn fail(diagnostic: Diagnostic) -> Self { + Self::Fail(Box::new(diagnostic)) + } +} + +fn resolve_field_project( + dag: &Dag, + t: &TransformNode, + field_label: &str, +) -> FieldProjectResolution { + if t.inputs.len() != 1 { + return FieldProjectResolution::fail(Diagnostic::ArityMismatch { + function: format!(".{field_label}"), + expected: 1, + actual: t.inputs.len(), + span: t.span.clone(), + correction: Correction::deferred_for_diagnostic_class("InferenceDiagnostic"), + }); + } + + let input_ty = match dag.port(t.inputs[0]).state() { + PortState::Uninferred => return FieldProjectResolution::Retry, + PortState::Unresolved => { + return FieldProjectResolution::fail(Diagnostic::ResolveError { + name: format!("(upstream failure in field `{field_label}`)"), + span: t.span.clone(), + correction: Correction::deferred_for_diagnostic_class("InferenceDiagnostic"), + }) + } + PortState::Resolved(ty) => *ty, + }; + + let mut subst = SubstStack::new(); + if let Some(opaque_decl) = first_nominal_opaque_on_conj_walk(dag, input_ty.declaration, &subst) + { + return FieldProjectResolution::fail(Diagnostic::NominalOpacityViolation { + declaration: opaque_decl, + accessor: None, + span: t.span.clone(), + correction: Correction::deferred_for_diagnostic_class("InferenceDiagnostic"), + }); + } + let Some(actual_conj_id) = walk_to_conj_decl_with_subst(dag, input_ty.declaration, &mut subst) + else { + return FieldProjectResolution::fail(Diagnostic::ResolveError { + name: format!( + "field `{field_label}` cannot be projected from `{}` because it does not walk to a Conj type", + target_display_name(dag, input_ty.declaration), + ), + span: t.span.clone(), + correction: Correction::deferred_for_diagnostic_class("InferenceDiagnostic"), + }); + }; + + let children = match &dag.declaration(actual_conj_id).connective { + TypeConnective::Conj { children } => children, + _ => unreachable!("walk_to_conj_decl returned a non-Conj declaration"), + }; + let Some(field_decl_id) = children + .iter() + .find(|field| field.label == field_label) + .map(|field| field.ty) + else { + let field_start = t.span.byte_end.saturating_sub(field_label.len() as u32); + let correction = match children.as_slice() { + [field] => Correction::live( + format!("did you mean field `{}`?", field.label), + SourceSpan::new(t.span.file.clone(), field_start, t.span.byte_end), + field.label.clone(), + ), + fields => { + let candidates = fields + .iter() + .map(|field| field.label.as_str()) + .collect::>() + .join(", "); + Correction::deferred( + format!( + "field `{field_label}` has multiple valid replacements: {candidates}" + ), + "R3 Gap 9 row #106 field-choice correction workflow", + "model structured alternative corrections so each candidate can be surfaced without choosing arbitrarily", + ) + } + }; + return FieldProjectResolution::fail(Diagnostic::ResolveError { + name: format!( + "field `{field_label}` does not exist on `{}`", + target_display_name(dag, input_ty.declaration), + ), + span: t.span.clone(), + correction, + }); + }; + let Some(output_ty) = walk_to_type_shape(dag, field_decl_id, &subst, 0) else { + return FieldProjectResolution::fail(Diagnostic::ResolveError { + name: format!( + "field `{field_label}` on `{}` does not resolve to a port type", + target_display_name(dag, input_ty.declaration), + ), + span: t.span.clone(), + correction: Correction::deferred_for_diagnostic_class("InferenceDiagnostic"), + }); + }; + + FieldProjectResolution::Resolved { output_ty } +} + +fn first_nominal_opaque_on_conj_walk( + dag: &Dag, + start: DeclarationId, + subst: &SubstStack, +) -> Option { + let mut subst = subst.clone(); + match walk_to_conj_decl_with_subst_or_opacity(dag, start, &mut subst, true) { + ConjWalkResult::NominalOpaque(id) => Some(id), + ConjWalkResult::Conj(_) | ConjWalkResult::NoConj => None, + } +} + +fn decide_field_project(dag: &Dag, t: &TransformNode, field_label: &str) -> Decision { + match resolve_field_project(dag, t, field_label) { + FieldProjectResolution::Retry => Decision::Retry, + FieldProjectResolution::Fail(diag) => Decision::fail(t.output, *diag), + FieldProjectResolution::Resolved { output_ty, .. } => Decision::Set(t.output, output_ty), + } +} + +fn resolve_field_project_targets(dag: &mut Dag) -> bool { + let mut rewrites: Vec<(usize, String)> = Vec::new(); + for (node_index, node) in dag.nodes().iter().enumerate() { + let Behavior::Transform(t) = node else { + continue; + }; + let TransformTarget::UnresolvedFieldProject { field_label } = &t.target else { + continue; + }; + let FieldProjectResolution::Resolved { .. } = resolve_field_project(dag, t, field_label) + else { + continue; + }; + rewrites.push((node_index, field_label.clone())); + } + + let mut changed = false; + for (node_index, field_label) in rewrites { + let Behavior::Transform(t) = &mut dag.nodes_mut()[node_index] else { + continue; + }; + let target = &mut t.target; + let TransformTarget::UnresolvedFieldProject { .. } = target else { + continue; + }; + *target = TransformTarget::ResolvedFieldProject { field_label }; + changed = true; + } + changed +} + +const WALK_DEPTH_LIMIT: usize = 32; +/// §8.9 operator dispatch via structural algebra walk. +/// +/// Walks the LHS type's declaration chain (following `Instantiation` +/// and `ResolvedIdentifier` edges) until it reaches an algebra +/// `Conj` declaration, then looks up the operator's field by name +/// (via `OperatorKind::algebra_field_name`). The field's Arrow +/// signature is read from the declaration graph — the compiler +/// consumes `std/algebra.dag` as authority rather than fabricating +/// signatures in Rust. +/// +/// **Receiver substitution rule.** The algebra's first type +/// parameter is the "receiver" — it represents "the inhabitant of +/// this algebra." When the algebra field's Arrow has `(T, T) -> T`, +/// the resolved signature substitutes `T → source_id` (the user's +/// `lhs_type` declaration, e.g., `Int`), not `T → Word64` (the +/// algebra instantiation's template argument). This keeps port +/// types consistent with user-facing primitives. Non-receiver +/// positions (`Bool` in the comparison arrows, `Ordering` in +/// `compare`) stay as-is. +/// +/// **Current coverage.** Works for scalar algebras whose receiver +/// is the first type parameter used directly: `OrderedRing`, +/// `Ring`, `Semiring`, `Field`, `Lattice`, +/// `BooleanAlgebra`. This is sufficient for `Int`, `Float`, and +/// any user type that aliases an `OrderedRing`/`Ring`/`Field` +/// instantiation. +/// +/// **Not yet covered** (tracked in DOWNSTREAM_REQUIREMENTS.md as +/// class-5 gaps): +/// - `Bool`: ~~no structural link~~ now uses `Declaration.inhabits` on +/// the kernel `Bool` sum in `dsl/std/types.dag`, populated at bootstrap +/// (`bootstrap::patch_kernel_bool_boolean_algebra_inhabits`) so v2 +/// keeps a plain `type Bool = True | False` surface. The walk follows +/// `Declaration.inhabits` when the connective chain hits a `Disj` +/// without an inline algebra. +/// - `String` / collection-level algebras whose receiver is +/// `FreeMonoid` / `Set` / `Map` (the whole +/// instantiation, not just `T`): needs a refinement to the +/// substitution rule. +/// +/// For those cases the resolver falls back to a Rust-side bridge +/// with a `(T, T) -> T` / `(T, T) -> Bool` signature. The fallback +/// is explicit about being a scaffold (see OperatorKind's +/// dissolution receipt). +/// +/// **Dedup invariant:** anonymous `Result<…, DivError>` instantiations +/// must reuse `find_equivalent_anonymous_instantiation` on every pass; +/// a missed structural match would mint fresh `DeclarationId`s each +/// iteration and the fixpoint loop would not terminate. +fn div_total_result_output_shape(dag: &mut Dag, base_lhs: TypeShape) -> Option { + let result_template = canonical_result_decl(dag)?.id; + let div_error = canonical_div_error_decl(dag)?.id; + let (t_ok, t_err, span) = { + let rdecl = dag.declaration(result_template); + ( + rdecl.type_params.first().copied()?, + rdecl.type_params.get(1).copied()?, + rdecl.span.clone(), + ) + }; + let args = vec![ + TemplateArgument { + parameter: t_ok, + value: base_lhs.declaration, + }, + TemplateArgument { + parameter: t_err, + value: div_error, + }, + ]; + if let Some(existing) = + find_equivalent_anonymous_instantiation(dag, result_template, &args, None) + { + return Some(TypeShape::new(existing)); + } + let id = dag.alloc_declaration_id(); + dag.push_declaration(Declaration { + id, + name: None, + connective: TypeConnective::Instantiation { + template: result_template, + arguments: args, + }, + type_params: Vec::new(), + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: None, + span, + }); + Some(TypeShape::new(id)) +} + +fn resolve_operator_arrow( + dag: &mut Dag, + op_kind: OperatorKind, + lhs_type: &TypeShape, +) -> Option { + // DB-11 (3a.3) operand normalization. Primitive operators (`+`, + // `>`, `!=`, etc.) are structurally over BASE types — refinements + // are surface-level facts about values, not part of the operator's + // arrow contract. Mirroring a refined lhs like `Int where d != 0` + // onto both operand positions (as the old fallback did) made the + // call-site refinement-discharge pass treat the refinement as a + // real requirement on every operand — so a literal `10` in + // `d > 10` failed discharge because literals carry no refinement. + // Strip refinements once up front; algebra-Conj walks and the + // primitive fallback both operate on the base. + let source_id = strip_refinement_to_base(dag, lhs_type.declaration); + let base_lhs = TypeShape::new(source_id); + // Walk the source type's declaration chain to find the algebra + // Conj. We follow Instantiation/ResolvedIdentifier edges; at + // each step the template argument bindings are intentionally + // discarded — see the receiver substitution rule in the doc + // comment. + let mut current = source_id; + let mut saw_algebra_conj = false; + for _ in 0..WALK_DEPTH_LIMIT { + let decl = dag.declaration(current).clone(); + match &decl.connective { + TypeConnective::Conj { children } => { + saw_algebra_conj = true; + // Found the algebra. Look up the operator's field by + // name. + let field_name = crate::operators::algebra_field_name(op_kind); + if let Some(field) = children.iter().find(|f| f.label == field_name) { + return read_algebra_field(dag, &decl, field.ty, source_id, op_kind, &base_lhs); + } + // Algebra doesn't declare this operator's field — + // fall back to the Rust-side scaffold bridge below. + break; + } + TypeConnective::Instantiation { template, .. } => { + current = *template; + } + TypeConnective::Atom(AtomPayload::ResolvedByStructure(next)) + | TypeConnective::Atom(AtomPayload::ResolvedByName(next)) => { + current = *next; + } + // Terminal connectives: try `Declaration.inhabits` (e.g. Bool + // → BooleanAlgebra) before giving up. + TypeConnective::Atom(AtomPayload::UnresolvedIdentifier(_)) + | TypeConnective::Atom(AtomPayload::TypeParam(_)) + | TypeConnective::Atom(AtomPayload::Literal(_)) + | TypeConnective::Disj { .. } + | TypeConnective::Arrow { .. } + | TypeConnective::Cardinality(_) => { + if let Some(inh) = decl.inhabits { + current = inh; + } else { + break; + } + } + } + } + // Fallback: Rust-side scaffold bridge for primitives whose + // structural walk doesn't terminate at an algebra Conj (Bool, + // Classical; collection-level algebras). Documented class-5 gap. + // + // Logical operators are Bool-monomorphic: both operands and the + // output are always Bool, independent of `lhs_type`. An Int lhs + // on `&&` / `||` must surface a type mismatch, not propagate + // through the operand slots the way Arithmetic / Comparison do. + // + // Fail-closed for LHS that the walk never reached any Conj for: + // every loop exit saw a terminal connective with no `inhabits`, + // an Arrow / Disj / Cardinality with no `inhabits`, or depth + // exhaustion. The pre-fix fallback fabricated `(T,T)->T` / + // `(T,T)->Bool` / `(Bool,Bool)->Bool` / `(T,T)->Result` + // for any such LHS — inventing an arrow contract for a type that + // declares none. Refuse synthesis here so the caller surfaces a + // typed unsupported-operator diagnostic. The Conj-reached-but- + // missing-field path (`saw_algebra_conj == true`) keeps its + // existing scaffold; tightening that further (distinguishing + // algebra carriers from arbitrary product structs) requires a + // structural signal beyond "is a Conj" — see PR #1329 STOP+PING. + if !saw_algebra_conj { + return None; + } + let (inputs, output) = match op_kind { + // Division leaves the class-5 scaffold: total shape matches algebra `div`. + OperatorKind::Arithmetic(ArithmeticOp::Div) => ( + vec![base_lhs, base_lhs], + div_total_result_output_shape(dag, base_lhs)?, + ), + OperatorKind::Arithmetic(_) => (vec![base_lhs, base_lhs], base_lhs), + OperatorKind::Comparison(_) => (vec![base_lhs, base_lhs], dag.bool_shape()?), + OperatorKind::Logical(_) => { + let bool_shape = dag.bool_shape()?; + (vec![bool_shape, bool_shape], bool_shape) + } + }; + Some(ResolvedArrow { + inputs, + output, + body: ArrowBody::Pending, + }) +} + +/// DB-11 (3a.3) refinement-strip helper. Walks the +/// `Atom(ResolvedIdentifier(...))` chain, skipping past any +/// declaration that carries a `refinement` edge, until it reaches a +/// declaration with no refinement. Used by `resolve_operator_arrow` +/// to normalize primitive-operator inputs to their base types — +/// refinements are surface-level facts about values, not part of an +/// operator's arrow contract. +/// +/// Also used by [`crate::emit::operator_carrier_realization`] so emit +/// and infer share one refinement-stripping rule (no drift). +/// +/// Terminates at the first un-refined declaration OR when the chain +/// can no longer be followed (any non-ResolvedIdentifier connective). +/// Depth-bounded by `WALK_DEPTH_LIMIT`. +pub(crate) fn strip_refinement_to_base(dag: &Dag, decl_id: DeclarationId) -> DeclarationId { + let mut current = decl_id; + for _ in 0..WALK_DEPTH_LIMIT { + let decl = dag.declaration(current); + if decl.refinement.is_none() { + return current; + } + match &decl.connective { + TypeConnective::Atom(AtomPayload::ResolvedByStructure(next)) + | TypeConnective::Atom(AtomPayload::ResolvedByName(next)) => { + current = *next; + } + _ => return current, + } + } + current +} + +/// Read an algebra field's Arrow signature and substitute the +/// algebra's receiver type parameter to the source declaration. +/// +/// The algebra field `field_ty` points at an Arrow declaration +/// emitted by `type_to_declaration_id` when lowering the algebra's +/// record type (e.g., `OrderedRing`'s `add: fn(T, T) -> T`). +/// The Arrow's input/output DeclarationIds reference the algebra's +/// receiver type parameter directly. Substitution replaces those +/// references with `source_id`; non-receiver positions walk to +/// their named anchor via the same walk `walk_to_type_shape` does. +fn read_algebra_field( + dag: &mut Dag, + algebra_decl: &crate::dag::Declaration, + field_ty: DeclarationId, + source_id: DeclarationId, + op_kind: OperatorKind, + lhs_type: &TypeShape, +) -> Option { + // The algebra's first type parameter is the receiver. For + // `OrderedRing` this is T's DeclarationId. + let receiver_param = algebra_decl.type_params.first().copied(); + // Unwrap the field's connective to the underlying Arrow. The + // field declaration was emitted by `type_to_declaration_id`'s + // Arrow arm as `TypeConnective::Arrow { inputs, output, body }`. + let field_decl = dag.declaration(field_ty); + let (arrow_inputs, arrow_output, arrow_body) = match &field_decl.connective { + TypeConnective::Arrow { + inputs, + output, + body, + } => (inputs.clone(), *output, body.clone()), + // Algebra field that isn't an Arrow (e.g., the `zero: T` + // constant). Not a callable operator — fall back. + _ => return None, + }; + // Translate each input / output DeclarationId through the + // receiver-substitution rule. + let input_shapes: Vec = arrow_inputs + .iter() + .map(|id| substitute_receiver(dag, *id, receiver_param, source_id).map(TypeShape::new)) + .collect::>()?; + let output_shape = + substitute_receiver(dag, arrow_output, receiver_param, source_id).map(TypeShape::new)?; + // Sanity check: the arity is always 2 for binary operators. + // If algebra.dag ever declares a field under one of our + // operator names with a different arity, the check downstream + // in `decide_transform` catches the mismatch as a + // `Diagnostic::ArityMismatch`, but we also want to preserve + // `lhs_type` in the signature as a debug fallback if the walk + // succeeds but the shape is unexpectedly wrong. + let _ = lhs_type; + let _ = op_kind; + Some(ResolvedArrow { + inputs: input_shapes, + output: output_shape, + body: arrow_body, + }) +} + +/// Substitute a declaration id through the receiver-substitution +/// rule: if the id matches the algebra's receiver type parameter, +/// return the source type; otherwise walk to the nearest named +/// declaration (same model as `walk_to_type_shape` but with the +/// receiver case short-circuited). +fn substitute_receiver( + dag: &mut Dag, + current: DeclarationId, + receiver_param: Option, + source_id: DeclarationId, +) -> Option { + if Some(current) == receiver_param { + return Some(source_id); + } + let decl = dag.declaration(current).clone(); + match &decl.connective { + TypeConnective::Atom(AtomPayload::ResolvedByStructure(next)) + | TypeConnective::Atom(AtomPayload::ResolvedByName(next)) => { + substitute_receiver(dag, *next, receiver_param, source_id) + } + TypeConnective::Instantiation { + template, + arguments, + } => { + let mut new_args: Vec = Vec::with_capacity(arguments.len()); + let mut any_change = false; + for arg in arguments { + let new_val = substitute_receiver(dag, arg.value, receiver_param, source_id)?; + if new_val != arg.value { + any_change = true; + } + new_args.push(TemplateArgument { + parameter: arg.parameter, + value: new_val, + }); + } + if !any_change { + return Some(current); + } + // Same dedup contract as `div_total_result_output_shape`: false negatives + // here allocate unbounded anonymous instantiations across fixpoint iterations. + if let Some(existing) = find_equivalent_anonymous_instantiation( + dag, + *template, + &new_args, + decl.nominal_opacity.as_ref(), + ) { + return Some(existing); + } + let id = dag.alloc_declaration_id(); + let span = decl.span.clone(); + dag.push_declaration(Declaration { + id, + name: None, + connective: TypeConnective::Instantiation { + template: *template, + arguments: new_args, + }, + type_params: Vec::new(), + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: decl.nominal_opacity.clone(), + span, + }); + Some(id) + } + TypeConnective::Atom(AtomPayload::TypeParam(_)) + | TypeConnective::Atom(AtomPayload::UnresolvedIdentifier(_)) => None, + _ => { + if decl.name.is_some() { + Some(current) + } else { + None + } + } + } +} + +fn canonical_result_decl(dag: &Dag) -> Option<&Declaration> { + dag.declarations() + .iter() + .find(|decl| substrate_result_type_decl_suppressed_for_emit(dag, decl)) +} + +fn canonical_div_error_decl(dag: &Dag) -> Option<&Declaration> { + dag.declarations() + .iter() + .find(|decl| substrate_div_error_type_decl_suppressed_for_emit(dag, decl)) +} + +/// Dispatch-time invariant check on an `ExternalRealization` target: +/// the linked declaration must be a `Conj` with a non-`None` +/// `meta_tag` edge. The meta_tag IS the realization marker — no +/// further name/id comparison is required. +/// +/// **Round-10 correction.** Earlier revisions compared `meta_tag` +/// against a cached `Dag::realization_meta_id()` pointing at a +/// `Realization` declaration. Production bootstrap doesn't load a +/// `Realization` declaration (realization facts live in +/// `dsl/extdeps/languages/*` per the thesis, not in the std/ set +/// the M1(2.7) bootstrap consumes), so the cache was always `None` +/// and the check always failed. The shape check is now purely +/// structural: "Conj with a meta_tag" is the realization marker. +/// The structural shape is what `bootstrap::assert_realization_shape` +/// and the `#[cfg(test)]` realization smoke test already validate +/// at construction time. Any drift is caught at both construction +/// and dispatch. +fn is_realization_shape(dag: &Dag, realization_id: DeclarationId) -> bool { + let decl = dag.declaration(realization_id); + if !matches!(decl.connective, TypeConnective::Conj { .. }) { + return false; + } + decl.meta_tag.is_some() +} + +/// Substrate walk: given a DeclarationId, descend through +/// `Instantiation` (pushing subst frames), `Atom(Identifier { resolved +/// })` (following the link), and `Atom(TypeParam)` (looking up the +/// subst stack) until an `Arrow` is reached. At the Arrow, substitute +/// each input and output DeclarationId through the subst stack and +/// bridge to `TypeShape` via `walk_to_type_shape`. +fn resolve_arrow_walk( + dag: &Dag, + current: DeclarationId, + subst: &mut SubstStack, + depth: usize, +) -> Option { + if depth >= WALK_DEPTH_LIMIT { + return None; + } + let decl = dag.declaration(current); + match &decl.connective { + TypeConnective::Arrow { + inputs, + output, + body, + } => { + let mut input_shapes: Vec = Vec::new(); + for id in inputs { + if subst.lookup(*id).is_some() && declaration_is_callable(dag, *id, depth + 1) { + continue; + } + input_shapes.push(signature_type_shape(dag, *id, subst, depth + 1)?); + } + let output_shape = signature_type_shape(dag, *output, subst, depth + 1)?; + Some(ResolvedArrow { + inputs: input_shapes, + output: output_shape, + body: body.clone(), + }) + } + TypeConnective::Instantiation { + template, + arguments, + } => { + subst.push(arguments.clone()); + let result = resolve_arrow_walk(dag, *template, subst, depth + 1); + subst.pop(); + result + } + TypeConnective::Atom(AtomPayload::ResolvedByStructure(next)) + | TypeConnective::Atom(AtomPayload::ResolvedByName(next)) => { + resolve_arrow_walk(dag, *next, subst, depth + 1) + } + // Terminal non-Arrow cases. Each is unreachable in well-formed + // user code; enumerated explicitly (rather than `_ => None`) so + // that any future `TypeConnective` or `AtomPayload` variant + // forces consideration here instead of silently falling through. + TypeConnective::Atom(AtomPayload::UnresolvedIdentifier(_)) => None, + TypeConnective::Atom(AtomPayload::TypeParam(_)) => None, + TypeConnective::Atom(AtomPayload::Literal(_)) => None, + TypeConnective::Conj { .. } => None, + TypeConnective::Disj { .. } => None, + TypeConnective::Cardinality(_) => None, + } +} + +/// Walk a type declaration down to a port-level `TypeShape`. +/// +/// `TypeShape` is a newtype around `DeclarationId` — port types ARE +/// declaration identities. The walk descends through anonymous +/// declarations until it finds a stable type identity: +/// +/// - named top-level declarations keep their own `DeclarationId` +/// - anonymous instantiations keep THEIR instantiation id so +/// `List` and bare `List` do not collapse to the same shape +/// - type params resolve through the substitution stack when bound +/// +/// There is no name-keyed bridge back to a coarse primitive tag — the +/// declaration graph IS the type identity. +fn walk_to_type_shape( + dag: &Dag, + current: DeclarationId, + subst: &SubstStack, + depth: usize, +) -> Option { + if depth >= WALK_DEPTH_LIMIT { + return None; + } + let decl = dag.declaration(current); + // Named top-level declaration: this is the port's type identity. + if decl.name.is_some() { + return Some(TypeShape::new(current)); + } + // Anonymous declaration: follow the chain through the substrate. + match &decl.connective { + TypeConnective::Atom(AtomPayload::TypeParam(_)) => { + if let Some(bound) = subst.lookup(current) { + walk_to_type_shape(dag, bound, subst, depth + 1) + } else { + Some(TypeShape::new(current)) + } + } + TypeConnective::Atom(AtomPayload::ResolvedByStructure(next)) + | TypeConnective::Atom(AtomPayload::ResolvedByName(next)) => { + walk_to_type_shape(dag, *next, subst, depth + 1) + } + TypeConnective::Instantiation { .. } => { + resolve_decl_with_subst(dag, current, subst, depth + 1) + .map(TypeShape::new) + .or_else(|| Some(TypeShape::new(current))) + } + // Terminal non-follow cases. An anonymous `UnresolvedIdentifier` + // means the sweep did not resolve the reference — the phantom + // diagnostic is already attached, and this walk fails so the + // caller can surface it. Anonymous optionals still need port + // identities today because reflected substrate fields such as + // `DagPort.produced_by: NodeId?` are legal field-project outputs. + // The broader anonymous structural cases stay fail-closed until + // port-type extension work admits them deliberately. + // Enumerated explicitly (rather than `_ => None`) so any + // future variant forces consideration here. + TypeConnective::Atom(AtomPayload::UnresolvedIdentifier(_)) => None, + TypeConnective::Atom(AtomPayload::Literal(_)) => None, + TypeConnective::Conj { .. } => None, + TypeConnective::Disj { .. } => None, + TypeConnective::Arrow { .. } => None, + TypeConnective::Cardinality(_) => Some(TypeShape::new(current)), + } +} + +fn signature_type_shape( + dag: &Dag, + current: DeclarationId, + subst: &SubstStack, + depth: usize, +) -> Option { + if depth >= WALK_DEPTH_LIMIT { + return None; + } + let decl = dag.declaration(current); + if decl.name.is_some() { + return Some(TypeShape::new(current)); + } + // DB-16 (3a.3 closure): read-only lookup gate. When the refinement + // base requires substitution, check whether + // `materialize_callable_signature_instantiations` has already + // produced a substituted-refined carrier for this + // (template_refined, subst) combination. If so, return that + // carrier. Construction lives at the phase; this gate reads. + if decl.refinement.is_some() && refinement_base_requires_substitution(dag, current, subst) { + if let Some(materialized) = find_equivalent_substituted_refined_decl(dag, current, subst) { + return Some(TypeShape::new(materialized)); + } + // Lookup miss: the phase didn't materialize for this + // combination (TypeParam unbound at phase time, or call-site + // outside the phase walk). Fall through to the DB-11 + // identity-terminator; downstream `is_retryable_generic_decl` + // classifies as retry. + } + // DB-11 (3a.3): refinement-bearing declarations are identity + // terminators for signature walks. Without this, a refined param + // type `Int where d != 0` (connective: ResolvedIdentifier(Int), + // refinement: Some(pred)) would be walked to its base `Int`, and + // the call-site refinement-discharge check in `decide_transform` + // would never see the predicate edge on the callee side. + if decl.refinement.is_some() { + return Some(TypeShape::new(current)); + } + match &decl.connective { + TypeConnective::Instantiation { .. } => { + resolve_decl_with_subst(dag, current, subst, depth + 1) + .map(TypeShape::new) + .or_else(|| Some(TypeShape::new(current))) + } + TypeConnective::Atom(AtomPayload::TypeParam(_)) => { + if let Some(bound) = subst.lookup(current) { + signature_type_shape(dag, bound, subst, depth + 1) + } else { + Some(TypeShape::new(current)) + } + } + TypeConnective::Atom(AtomPayload::ResolvedByStructure(next)) + | TypeConnective::Atom(AtomPayload::ResolvedByName(next)) => { + signature_type_shape(dag, *next, subst, depth + 1) + } + TypeConnective::Atom(AtomPayload::UnresolvedIdentifier(_)) => None, + TypeConnective::Atom(AtomPayload::Literal(_)) => None, + TypeConnective::Conj { .. } => None, + TypeConnective::Disj { .. } => None, + TypeConnective::Arrow { .. } => None, + // Cardinality-typed signatures (e.g., `fn port(d, id) -> DagPort?`) + // keep the anonymous Cardinality declaration id as the port's type + // identity. Mirrors `walk_to_type_shape`'s Cardinality case — + // optional returns are legal throughout the substrate. + TypeConnective::Cardinality(_) => Some(TypeShape::new(current)), + } +} + +fn resolve_decl_with_subst( + dag: &Dag, + current: DeclarationId, + subst: &SubstStack, + depth: usize, +) -> Option { + if depth >= WALK_DEPTH_LIMIT { + return None; + } + let decl = dag.declaration(current); + match &decl.connective { + TypeConnective::Atom(AtomPayload::TypeParam(_)) => subst + .lookup(current) + .and_then(|bound| resolve_decl_with_subst(dag, bound, subst, depth + 1)) + .or(Some(current)), + TypeConnective::Atom(AtomPayload::ResolvedByStructure(next)) + | TypeConnective::Atom(AtomPayload::ResolvedByName(next)) => { + resolve_decl_with_subst(dag, *next, subst, depth + 1) + } + TypeConnective::Instantiation { + template, + arguments, + } => { + let specialized_arguments: Vec = arguments + .iter() + .map(|arg| { + Some(TemplateArgument { + parameter: arg.parameter, + value: resolve_decl_with_subst(dag, arg.value, subst, depth + 1)?, + }) + }) + .collect::>()?; + if specialized_arguments + .iter() + .zip(arguments.iter()) + .all(|(lhs, rhs)| lhs.parameter == rhs.parameter && lhs.value == rhs.value) + { + return Some(current); + } + find_equivalent_decl_instantiation(dag, *template, &specialized_arguments) + .or(Some(current)) + } + TypeConnective::Cardinality(p) => { + let element = p.element(); + let bound = p.bound(); + let specialized_element = resolve_decl_with_subst(dag, element, subst, depth + 1)?; + if bound == crate::dag::CardinalityBound::AtMostOne { + if let Some(idem) = + crate::dag::cardinality_idempotent_target(dag, specialized_element, bound) + { + return Some(idem); + } + } + if specialized_element == element { + return Some(current); + } + find_equivalent_decl_cardinality(dag, specialized_element, &bound).or(Some(current)) + } + _ => Some(current), + } +} + +fn find_equivalent_decl_instantiation( + dag: &Dag, + template: DeclarationId, + arguments: &[TemplateArgument], +) -> Option { + dag.declarations().iter().find_map(|decl| { + let TypeConnective::Instantiation { + template: existing_template, + arguments: existing_arguments, + } = &decl.connective + else { + return None; + }; + (template == *existing_template + && existing_arguments.len() == arguments.len() + && existing_arguments + .iter() + .zip(arguments.iter()) + .all(|(lhs, rhs)| lhs.parameter == rhs.parameter && lhs.value == rhs.value)) + .then_some(decl.id) + }) +} + +fn find_equivalent_decl_cardinality( + dag: &Dag, + element: DeclarationId, + bound: &crate::dag::CardinalityBound, +) -> Option { + dag.declarations().iter().find_map(|decl| { + let TypeConnective::Cardinality(existing) = &decl.connective else { + return None; + }; + (element == existing.element() && existing.bound() == *bound).then_some(decl.id) + }) +} + +/// Best-effort human-readable name for a Transform.target DeclarationId, +/// used in diagnostics. Walks through resolved Identifier atoms to find +/// something nameable. +fn target_display_name(dag: &Dag, target: DeclarationId) -> String { + let decl = dag.declaration(target); + if let Some(name) = &decl.name { + return name.clone(); + } + match &decl.connective { + TypeConnective::Atom(AtomPayload::UnresolvedIdentifier(name)) => name.clone(), + TypeConnective::Atom(AtomPayload::ResolvedByStructure(next)) + | TypeConnective::Atom(AtomPayload::ResolvedByName(next)) => { + target_display_name(dag, *next) + } + _ => format!("declaration#{}", target.raw()), + } +} + +/// Best-effort human-readable name for a `TransformTarget`. Dispatches +/// on the variant: `Callable` goes through `target_display_name`, +/// `Operator` renders the source symbol directly. +fn transform_target_display_name(dag: &Dag, target: &TransformTarget) -> String { + match target { + TransformTarget::Callable(id) => target_display_name(dag, *id), + TransformTarget::UnresolvedFieldProject { field_label } => format!(".{field_label}"), + TransformTarget::ResolvedFieldProject { field_label } => format!(".{field_label}"), + TransformTarget::Operator(op_kind) => crate::operators::symbol(*op_kind), + } +} + +fn node_span_for_port(dag: &Dag, port: PortId) -> Option { + dag.port(port) + .produced_by + .map(|node_id| dag.node(node_id).span().clone()) +} + +fn synthetic_span() -> SourceSpan { + SourceSpan::new("", 0, 0) +} + +pub(crate) fn type_shapes_equivalent(dag: &Dag, lhs: &TypeShape, rhs: &TypeShape) -> bool { + if lhs == rhs { + return true; + } + declaration_shapes_equivalent(dag, lhs.declaration, rhs.declaration, 0) +} + +fn declaration_shapes_equivalent( + dag: &Dag, + lhs: DeclarationId, + rhs: DeclarationId, + depth: usize, +) -> bool { + if lhs == rhs { + return true; + } + if depth >= WALK_DEPTH_LIMIT { + return false; + } + let lhs_decl = dag.declaration(lhs); + let rhs_decl = dag.declaration(rhs); + match (&lhs_decl.connective, &rhs_decl.connective) { + ( + TypeConnective::Atom(AtomPayload::ResolvedByStructure(next)) + | TypeConnective::Atom(AtomPayload::ResolvedByName(next)), + _, + ) => declaration_shapes_equivalent(dag, *next, rhs, depth + 1), + ( + _, + TypeConnective::Atom(AtomPayload::ResolvedByStructure(next)) + | TypeConnective::Atom(AtomPayload::ResolvedByName(next)), + ) => declaration_shapes_equivalent(dag, lhs, *next, depth + 1), + ( + TypeConnective::Instantiation { + template: lhs_template, + arguments: lhs_arguments, + }, + TypeConnective::Instantiation { + template: rhs_template, + arguments: rhs_arguments, + }, + ) => { + declaration_shapes_equivalent(dag, *lhs_template, *rhs_template, depth + 1) + && phantom_arguments_equivalent_for_shape( + dag, + *lhs_template, + lhs_arguments, + *rhs_template, + rhs_arguments, + ) + && lhs_arguments.len() == rhs_arguments.len() + && lhs_arguments + .iter() + .zip(rhs_arguments.iter()) + .all(|(lhs_arg, rhs_arg)| { + declaration_shapes_equivalent(dag, lhs_arg.value, rhs_arg.value, depth + 1) + }) + } + (TypeConnective::Cardinality(lhs_p), TypeConnective::Cardinality(rhs_p)) => { + lhs_p.bound() == rhs_p.bound() + && declaration_shapes_equivalent(dag, lhs_p.element(), rhs_p.element(), depth + 1) + } + ( + TypeConnective::Conj { + children: lhs_children, + }, + TypeConnective::Conj { + children: rhs_children, + }, + ) => { + lhs_children.len() == rhs_children.len() + && lhs_children + .iter() + .zip(rhs_children.iter()) + .all(|(lhs_child, rhs_child)| { + lhs_child.label == rhs_child.label + && declaration_shapes_equivalent( + dag, + lhs_child.ty, + rhs_child.ty, + depth + 1, + ) + }) + } + ( + TypeConnective::Disj { + variants: lhs_variants, + }, + TypeConnective::Disj { + variants: rhs_variants, + }, + ) => { + lhs_variants.len() == rhs_variants.len() + && lhs_variants + .iter() + .zip(rhs_variants.iter()) + .all(|(lhs_variant, rhs_variant)| { + lhs_variant.label == rhs_variant.label + && declaration_shapes_equivalent( + dag, + lhs_variant.ty, + rhs_variant.ty, + depth + 1, + ) + }) + } + _ => false, + } +} + +fn phantom_arguments_equivalent_for_shape( + dag: &Dag, + lhs_template: DeclarationId, + lhs_arguments: &[TemplateArgument], + rhs_template: DeclarationId, + rhs_arguments: &[TemplateArgument], +) -> bool { + phantom_arguments_preserved_from_template( + dag, + lhs_template, + lhs_arguments, + rhs_template, + rhs_arguments, + ) && phantom_arguments_preserved_from_template( + dag, + rhs_template, + rhs_arguments, + lhs_template, + lhs_arguments, + ) +} + +fn phantom_arguments_preserved_from_template( + dag: &Dag, + source_template: DeclarationId, + source_arguments: &[TemplateArgument], + target_template: DeclarationId, + target_arguments: &[TemplateArgument], +) -> bool { + let source_decl = dag.declaration(source_template); + for phantom in &source_decl.phantom_params { + if require_abelian_group_phantom_algebra( + dag, + phantom.algebra, + phantom.parameter, + &synthetic_span(), + ) + .is_err() + { + return false; + } + let Some(target_phantom) = + mapped_phantom_parameter(dag, source_template, phantom, target_template) + else { + return false; + }; + if require_abelian_group_phantom_algebra( + dag, + target_phantom.algebra, + target_phantom.parameter, + &synthetic_span(), + ) + .is_err() + { + return false; + } + if !phantom_algebras_equivalent(dag, phantom.algebra, target_phantom.algebra) { + return false; + } + let Some(source_arg) = source_arguments + .iter() + .find(|arg| arg.parameter == phantom.parameter) + else { + return false; + }; + let Some(target_arg) = target_arguments + .iter() + .find(|arg| arg.parameter == target_phantom.parameter) + else { + return false; + }; + if source_arg.value != target_arg.value { + return false; + } + } + true +} + +#[cfg(test)] +mod bool_logical_operator_arrow_tests { + use super::*; + use crate::dag::PhantomParameter; + use crate::infer_helpers::filter_non_self_template_arguments; + use crate::operators::{ArithmeticOp, ComparisonOp}; + + #[test] + fn nominal_opaque_field_project_fails_closed_before_structural_descent() { + let mut dag = Dag::new(); + let span = SourceSpan::new("", 0, 1); + let int = dag.int_shape().expect("bootstrap Int").declaration; + + let payload = dag.alloc_declaration_id(); + dag.push_declaration(Declaration { + id: payload, + name: Some("OpaquePayload".to_string()), + connective: TypeConnective::Conj { + children: vec![Field { + label: "value".to_string(), + ty: int, + }], + }, + type_params: Vec::new(), + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: None, + span: span.clone(), + }); + + let opaque = dag.alloc_declaration_id(); + dag.push_declaration(Declaration { + id: opaque, + name: Some("OpaqueWrapper".to_string()), + connective: TypeConnective::Atom(AtomPayload::ResolvedByStructure(payload)), + type_params: Vec::new(), + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: Some(NominalOpacity { + permitted_accessors: Vec::new(), + }), + span: span.clone(), + }); + + let input = dag.alloc_port_with_shape(TypeShape::new(opaque)); + let output = dag.push_transform( + TransformTarget::UnresolvedFieldProject { + field_label: "value".to_string(), + }, + vec![input], + span, + ); + + infer(&mut dag); + + assert!(matches!( + dag.diagnostics().get(output), + Some(Diagnostic::NominalOpacityViolation { + declaration, + accessor: None, + .. + }) if *declaration == opaque + )); + assert!( + matches!(dag.port(output).state(), PortState::Unresolved), + "opacity violation must leave the projected output unresolved" + ); + } + + #[test] + fn nominal_opaque_field_project_preserves_instantiation_substitution() { + let mut dag = Dag::new(); + let span = SourceSpan::new("", 0, 1); + let int = dag.int_shape().expect("bootstrap Int").declaration; + + let payload = dag.alloc_declaration_id(); + dag.push_declaration(Declaration { + id: payload, + name: Some("SecretPayloadForSubst".to_string()), + connective: TypeConnective::Conj { + children: vec![Field { + label: "value".to_string(), + ty: int, + }], + }, + type_params: Vec::new(), + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: None, + span: span.clone(), + }); + let secret = dag.alloc_declaration_id(); + dag.push_declaration(Declaration { + id: secret, + name: Some("SecretForSubst".to_string()), + connective: TypeConnective::Atom(AtomPayload::ResolvedByStructure(payload)), + type_params: Vec::new(), + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: Some(NominalOpacity { + permitted_accessors: Vec::new(), + }), + span: span.clone(), + }); + let box_param = dag.alloc_declaration_id(); + dag.push_declaration(Declaration { + id: box_param, + name: None, + connective: TypeConnective::Atom(AtomPayload::TypeParam("T".to_string())), + type_params: Vec::new(), + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: None, + span: span.clone(), + }); + let box_alias = dag.alloc_declaration_id(); + dag.push_declaration(Declaration { + id: box_alias, + name: Some("BoxAlias".to_string()), + connective: TypeConnective::Atom(AtomPayload::ResolvedByStructure(box_param)), + type_params: vec![box_param], + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: None, + span: span.clone(), + }); + let boxed_secret = dag.alloc_declaration_id(); + dag.push_declaration(Declaration { + id: boxed_secret, + name: Some("BoxedSecret".to_string()), + connective: TypeConnective::Instantiation { + template: box_alias, + arguments: vec![TemplateArgument { + parameter: box_param, + value: secret, + }], + }, + type_params: Vec::new(), + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: None, + span: span.clone(), + }); + + let input = dag.alloc_port_with_shape(TypeShape::new(boxed_secret)); + let output = dag.push_transform( + TransformTarget::UnresolvedFieldProject { + field_label: "value".to_string(), + }, + vec![input], + span, + ); + + infer(&mut dag); + + assert!(matches!( + dag.diagnostics().get(output), + Some(Diagnostic::NominalOpacityViolation { + declaration, + accessor: None, + .. + }) if *declaration == secret + )); + } + + #[test] + fn field_project_without_nominal_opacity_remains_structural() { + let mut dag = Dag::new(); + let span = SourceSpan::new("", 0, 1); + let int = dag.int_shape().expect("bootstrap Int").declaration; + + let record = dag.alloc_declaration_id(); + dag.push_declaration(Declaration { + id: record, + name: Some("PlainPayload".to_string()), + connective: TypeConnective::Conj { + children: vec![Field { + label: "value".to_string(), + ty: int, + }], + }, + type_params: Vec::new(), + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: None, + span: span.clone(), + }); + + let input = dag.alloc_port_with_shape(TypeShape::new(record)); + let output = dag.push_transform( + TransformTarget::UnresolvedFieldProject { + field_label: "value".to_string(), + }, + vec![input], + span, + ); + + infer(&mut dag); + + assert!( + dag.diagnostics().get(output).is_none(), + "plain structural projection should not emit opacity diagnostics" + ); + assert!(matches!( + dag.port(output).state(), + PortState::Resolved(shape) if shape.declaration == int + )); + } + + #[test] + fn bootstrapped_secret_is_marked_nominal_opaque() { + let dag = Dag::new(); + let secret = dag + .declaration_by_name("Secret") + .expect("bootstrap Secret declaration"); + assert!( + secret.nominal_opacity.is_some(), + "Secret must consume the nominal-opacity carrier before Modeling dispatch" + ); + } + + #[test] + fn arithmetic_on_non_algebra_lhs_fails_closed_instead_of_synthesizing_arrow() { + // Director audit (post-merge finding): pre-fix, when the + // structural walk in `resolve_operator_arrow` did not encounter + // any algebra Conj on the LHS (e.g. an Arrow / Disj / Atom with + // no `inhabits` link), the fallback fabricated `(T,T)->T` for + // arithmetic and `(T,T)->Bool` for comparison. That invented + // an operator-rule arrow contract for a type that declares + // none. With the fail-closed check in place, the resolver + // returns None so the call site emits a typed + // unsupported-operator diagnostic. + let mut dag = Dag::new(); + let span = SourceSpan::new("", 0, 1); + let int = dag.int_shape().expect("bootstrap Int").declaration; + + // A non-algebra LHS: an anonymous Arrow declaration. No + // `inhabits`, no Conj on the walk path. + let arrow_decl = dag.alloc_declaration_id(); + dag.push_declaration(Declaration { + id: arrow_decl, + name: None, + connective: TypeConnective::Arrow { + inputs: vec![int], + output: int, + body: ArrowBody::NoBody, + }, + type_params: Vec::new(), + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: None, + span: span.clone(), + }); + let lhs = TypeShape::new(arrow_decl); + + assert!( + resolve_operator_arrow(&mut dag, OperatorKind::Arithmetic(ArithmeticOp::Add), &lhs,) + .is_none(), + "`+` on a non-algebra LHS must fail-closed: the pre-fix \ + fallback fabricated `(Arrow,Arrow)->Arrow` instead of \ + surfacing an unsupported-operator diagnostic" + ); + assert!( + resolve_operator_arrow(&mut dag, OperatorKind::Comparison(ComparisonOp::Lt), &lhs,) + .is_none(), + "`<` on a non-algebra LHS must fail-closed: the pre-fix \ + fallback fabricated `(Arrow,Arrow)->Bool` instead of \ + surfacing an unsupported-operator diagnostic" + ); + assert!( + resolve_operator_arrow(&mut dag, OperatorKind::Arithmetic(ArithmeticOp::Div), &lhs,) + .is_none(), + "`/` on a non-algebra LHS must fail-closed: the pre-fix \ + Div fallback synthesized `(Arrow,Arrow)->Result`" + ); + assert!( + resolve_operator_arrow(&mut dag, OperatorKind::Logical(LogicalOp::And), &lhs,) + .is_none(), + "`&&` on a non-algebra LHS must fail-closed: the pre-fix \ + logical fallback produced `(Bool,Bool)->Bool` regardless \ + of LHS, inventing a carrier the type does not declare" + ); + } + + #[test] + fn bool_logical_and_resolves_via_boolean_algebra_meet_not_pending_fallback() { + let mut dag = Dag::new(); + let bool_shape = dag.bool_shape().expect("bootstrap Bool"); + let sig = + resolve_operator_arrow(&mut dag, OperatorKind::Logical(LogicalOp::And), &bool_shape) + .expect("&& should resolve on Bool"); + assert!( + !matches!(sig.body, ArrowBody::Pending), + "expected Bool && via inhabits → BooleanAlgebra.meet, not Pending scaffold; got {:?}", + sig.body + ); + assert_eq!(sig.inputs.len(), 2); + assert_eq!(sig.inputs[0].declaration, bool_shape.declaration); + assert_eq!(sig.inputs[1].declaration, bool_shape.declaration); + assert_eq!(sig.output.declaration, bool_shape.declaration); + } + + #[test] + fn bool_logical_or_resolves_via_boolean_algebra_join_not_pending_fallback() { + let mut dag = Dag::new(); + let bool_shape = dag.bool_shape().expect("bootstrap Bool"); + let sig = + resolve_operator_arrow(&mut dag, OperatorKind::Logical(LogicalOp::Or), &bool_shape) + .expect("|| should resolve on Bool"); + assert!( + !matches!(sig.body, ArrowBody::Pending), + "expected Bool || via inhabits → BooleanAlgebra.join, not Pending scaffold; got {:?}", + sig.body + ); + assert_eq!(sig.inputs.len(), 2); + assert_eq!(sig.inputs[0].declaration, bool_shape.declaration); + assert_eq!(sig.inputs[1].declaration, bool_shape.declaration); + assert_eq!(sig.output.declaration, bool_shape.declaration); + } + + #[test] + fn arithmetic_operator_checks_abelian_phantom_unit_closure_before_type_mismatch() { + let mut dag = Dag::new(); + let span = SourceSpan::new("", 0, 1); + let int = dag.int_shape().expect("bootstrap Int").declaration; + let abelian_group = dag + .declaration_by_name("AbelianGroup") + .expect("bootstrap AbelianGroup") + .id; + + let currency = dag.alloc_declaration_id(); + dag.push_declaration(Declaration { + id: currency, + name: Some("Currency".to_string()), + connective: TypeConnective::Conj { children: vec![] }, + type_params: Vec::new(), + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: None, + span: span.clone(), + }); + let usd = dag.alloc_declaration_id(); + dag.push_declaration(Declaration { + id: usd, + name: Some("USD".to_string()), + connective: TypeConnective::Instantiation { + template: currency, + arguments: vec![], + }, + type_params: Vec::new(), + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: None, + span: span.clone(), + }); + let eur = dag.alloc_declaration_id(); + dag.push_declaration(Declaration { + id: eur, + name: Some("EUR".to_string()), + connective: TypeConnective::Instantiation { + template: currency, + arguments: vec![], + }, + type_params: Vec::new(), + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: None, + span: span.clone(), + }); + let currency_abelian_group = dag.alloc_declaration_id(); + let abelian_receiver = dag + .declaration(abelian_group) + .type_params + .first() + .copied() + .expect("AbelianGroup receiver type parameter"); + dag.push_declaration(Declaration { + id: currency_abelian_group, + name: Some("CurrencyAbelianGroup".to_string()), + connective: TypeConnective::Instantiation { + template: abelian_group, + arguments: vec![TemplateArgument { + parameter: abelian_receiver, + value: currency, + }], + }, + type_params: Vec::new(), + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: None, + span: span.clone(), + }); + let int_abelian_group = dag.alloc_declaration_id(); + dag.push_declaration(Declaration { + id: int_abelian_group, + name: Some("IntAbelianGroup".to_string()), + connective: TypeConnective::Instantiation { + template: abelian_group, + arguments: vec![TemplateArgument { + parameter: abelian_receiver, + value: int, + }], + }, + type_params: Vec::new(), + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: None, + span: span.clone(), + }); + let money = dag.alloc_declaration_id(); + let c_param = dag.alloc_declaration_id(); + dag.push_declaration(Declaration { + id: money, + name: Some("Money".to_string()), + connective: TypeConnective::Conj { + children: vec![Field { + label: "amount".to_string(), + ty: int, + }], + }, + type_params: vec![c_param], + phantom_params: vec![PhantomParameter { + parameter: c_param, + algebra: currency_abelian_group, + }], + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: None, + span: span.clone(), + }); + dag.push_declaration(Declaration { + id: c_param, + name: None, + connective: TypeConnective::Atom(AtomPayload::TypeParam("C".to_string())), + type_params: Vec::new(), + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: None, + span: span.clone(), + }); + + let money_usd = dag.alloc_declaration_id(); + dag.push_declaration(Declaration { + id: money_usd, + name: Some("MoneyUSD".to_string()), + connective: TypeConnective::Instantiation { + template: money, + arguments: vec![TemplateArgument { + parameter: c_param, + value: usd, + }], + }, + type_params: Vec::new(), + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: None, + span: span.clone(), + }); + let money_eur = dag.alloc_declaration_id(); + dag.push_declaration(Declaration { + id: money_eur, + name: Some("MoneyEUR".to_string()), + connective: TypeConnective::Instantiation { + template: money, + arguments: vec![TemplateArgument { + parameter: c_param, + value: eur, + }], + }, + type_params: Vec::new(), + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: None, + span: span.clone(), + }); + let money_c = dag.alloc_declaration_id(); + dag.push_declaration(Declaration { + id: money_c, + name: Some("MoneyC".to_string()), + connective: TypeConnective::Instantiation { + template: money, + arguments: vec![TemplateArgument { + parameter: c_param, + value: c_param, + }], + }, + type_params: Vec::new(), + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: None, + span: span.clone(), + }); + let alt_c_param = dag.alloc_declaration_id(); + dag.push_declaration(Declaration { + id: alt_c_param, + name: None, + connective: TypeConnective::Atom(AtomPayload::TypeParam("AltC".to_string())), + type_params: Vec::new(), + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: None, + span: span.clone(), + }); + let alt_money = dag.alloc_declaration_id(); + dag.push_declaration(Declaration { + id: alt_money, + name: Some("AltMoney".to_string()), + connective: TypeConnective::Conj { + children: vec![Field { + label: "amount".to_string(), + ty: int, + }], + }, + type_params: vec![alt_c_param], + phantom_params: vec![PhantomParameter { + parameter: alt_c_param, + algebra: currency_abelian_group, + }], + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: None, + span: span.clone(), + }); + let alt_money_usd = dag.alloc_declaration_id(); + dag.push_declaration(Declaration { + id: alt_money_usd, + name: Some("AltMoneyUSD".to_string()), + connective: TypeConnective::Instantiation { + template: alt_money, + arguments: vec![TemplateArgument { + parameter: alt_c_param, + value: usd, + }], + }, + type_params: Vec::new(), + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: None, + span: span.clone(), + }); + let alt_money_eur = dag.alloc_declaration_id(); + dag.push_declaration(Declaration { + id: alt_money_eur, + name: Some("AltMoneyEUR".to_string()), + connective: TypeConnective::Instantiation { + template: alt_money, + arguments: vec![TemplateArgument { + parameter: alt_c_param, + value: eur, + }], + }, + type_params: Vec::new(), + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: None, + span: span.clone(), + }); + let other_algebra_money = dag.alloc_declaration_id(); + dag.push_declaration(Declaration { + id: other_algebra_money, + name: Some("OtherAlgebraMoney".to_string()), + connective: TypeConnective::Conj { + children: vec![Field { + label: "amount".to_string(), + ty: int, + }], + }, + type_params: vec![alt_c_param], + phantom_params: vec![PhantomParameter { + parameter: alt_c_param, + algebra: int_abelian_group, + }], + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: None, + span: span.clone(), + }); + let other_algebra_money_usd = dag.alloc_declaration_id(); + dag.push_declaration(Declaration { + id: other_algebra_money_usd, + name: Some("OtherAlgebraMoneyUSD".to_string()), + connective: TypeConnective::Instantiation { + template: other_algebra_money, + arguments: vec![TemplateArgument { + parameter: alt_c_param, + value: usd, + }], + }, + type_params: Vec::new(), + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: None, + span: span.clone(), + }); + + let lhs = dag.alloc_port_with_shape(TypeShape::new(money_usd)); + let rhs_same = dag.alloc_port_with_shape(TypeShape::new(money_usd)); + let rhs_other = dag.alloc_port_with_shape(TypeShape::new(money_eur)); + let cross_template_rhs_other = dag.alloc_port_with_shape(TypeShape::new(alt_money_eur)); + let ok_output = dag.push_transform( + TransformTarget::Operator(OperatorKind::Arithmetic(ArithmeticOp::Add)), + vec![lhs, rhs_same], + span.clone(), + ); + let bad_output = dag.push_transform( + TransformTarget::Operator(OperatorKind::Arithmetic(ArithmeticOp::Add)), + vec![lhs, rhs_other], + span.clone(), + ); + let cross_template_bad_output = dag.push_transform( + TransformTarget::Operator(OperatorKind::Arithmetic(ArithmeticOp::Add)), + vec![lhs, cross_template_rhs_other], + span.clone(), + ); + assert!( + type_shapes_equivalent( + &dag, + &TypeShape::new(money_usd), + &TypeShape::new(alt_money_usd) + ), + "structural equivalence should map phantom axes across equivalent templates" + ); + assert!( + !type_shapes_equivalent( + &dag, + &TypeShape::new(money_usd), + &TypeShape::new(other_algebra_money_usd), + ), + "structural equivalence must compare mapped phantom algebra authority" + ); + let money_missing_arg = dag.alloc_declaration_id(); + dag.push_declaration(Declaration { + id: money_missing_arg, + name: Some("MoneyMissingArg".to_string()), + connective: TypeConnective::Instantiation { + template: money, + arguments: Vec::new(), + }, + type_params: Vec::new(), + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: None, + span: span.clone(), + }); + let malformed_lhs = dag.alloc_port_with_shape(TypeShape::new(money_missing_arg)); + let malformed_rhs = dag.alloc_port_with_shape(TypeShape::new(money_missing_arg)); + let malformed_output = dag.push_transform( + TransformTarget::Operator(OperatorKind::Arithmetic(ArithmeticOp::Add)), + vec![malformed_lhs, malformed_rhs], + span.clone(), + ); + let rogue_param = dag.alloc_declaration_id(); + dag.push_declaration(Declaration { + id: rogue_param, + name: None, + connective: TypeConnective::Atom(AtomPayload::TypeParam("Rogue".to_string())), + type_params: Vec::new(), + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: None, + span: span.clone(), + }); + let bad_money = dag.alloc_declaration_id(); + dag.push_declaration(Declaration { + id: bad_money, + name: Some("BadMoney".to_string()), + connective: TypeConnective::Conj { + children: vec![Field { + label: "amount".to_string(), + ty: int, + }], + }, + type_params: vec![c_param], + phantom_params: vec![PhantomParameter { + parameter: rogue_param, + algebra: currency_abelian_group, + }], + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: None, + span: span.clone(), + }); + let bad_money_usd = dag.alloc_declaration_id(); + dag.push_declaration(Declaration { + id: bad_money_usd, + name: Some("BadMoneyUSD".to_string()), + connective: TypeConnective::Instantiation { + template: bad_money, + arguments: vec![TemplateArgument { + parameter: rogue_param, + value: usd, + }], + }, + type_params: Vec::new(), + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: None, + span, + }); + let invalid_lhs = dag.alloc_port_with_shape(TypeShape::new(bad_money_usd)); + let invalid_rhs = dag.alloc_port_with_shape(TypeShape::new(bad_money_usd)); + let invalid_output = dag.push_transform( + TransformTarget::Operator(OperatorKind::Arithmetic(ArithmeticOp::Add)), + vec![invalid_lhs, invalid_rhs], + SourceSpan::new("", 1, 2), + ); + let unsupported_algebra = dag.alloc_declaration_id(); + dag.push_declaration(Declaration { + id: unsupported_algebra, + name: Some("CurrencyMonoid".to_string()), + connective: TypeConnective::Conj { children: vec![] }, + type_params: Vec::new(), + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: None, + span: SourceSpan::new("", 2, 3), + }); + let unsupported_money = dag.alloc_declaration_id(); + dag.push_declaration(Declaration { + id: unsupported_money, + name: Some("UnsupportedMoney".to_string()), + connective: TypeConnective::Conj { + children: vec![Field { + label: "amount".to_string(), + ty: int, + }], + }, + type_params: vec![c_param], + phantom_params: vec![PhantomParameter { + parameter: c_param, + algebra: unsupported_algebra, + }], + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: None, + span: SourceSpan::new("", 2, 3), + }); + let unsupported_money_usd = dag.alloc_declaration_id(); + dag.push_declaration(Declaration { + id: unsupported_money_usd, + name: Some("UnsupportedMoneyUSD".to_string()), + connective: TypeConnective::Instantiation { + template: unsupported_money, + arguments: vec![TemplateArgument { + parameter: c_param, + value: usd, + }], + }, + type_params: Vec::new(), + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: None, + span: SourceSpan::new("", 2, 3), + }); + let unsupported_lhs = dag.alloc_port_with_shape(TypeShape::new(unsupported_money_usd)); + let unsupported_rhs = dag.alloc_port_with_shape(TypeShape::new(unsupported_money_usd)); + let unsupported_output = dag.push_transform( + TransformTarget::Operator(OperatorKind::Arithmetic(ArithmeticOp::Add)), + vec![unsupported_lhs, unsupported_rhs], + SourceSpan::new("", 2, 3), + ); + let fake_abelian_group = dag.alloc_declaration_id(); + dag.push_declaration(Declaration { + id: fake_abelian_group, + name: Some("AbelianGroup".to_string()), + connective: TypeConnective::Conj { children: vec![] }, + type_params: Vec::new(), + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: None, + span: SourceSpan::new("", 3, 4), + }); + let fake_algebra_money = dag.alloc_declaration_id(); + dag.push_declaration(Declaration { + id: fake_algebra_money, + name: Some("FakeAlgebraMoney".to_string()), + connective: TypeConnective::Conj { + children: vec![Field { + label: "amount".to_string(), + ty: int, + }], + }, + type_params: vec![c_param], + phantom_params: vec![PhantomParameter { + parameter: c_param, + algebra: fake_abelian_group, + }], + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: None, + span: SourceSpan::new("", 3, 4), + }); + let fake_algebra_money_usd = dag.alloc_declaration_id(); + dag.push_declaration(Declaration { + id: fake_algebra_money_usd, + name: Some("FakeAlgebraMoneyUSD".to_string()), + connective: TypeConnective::Instantiation { + template: fake_algebra_money, + arguments: vec![TemplateArgument { + parameter: c_param, + value: usd, + }], + }, + type_params: Vec::new(), + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: None, + span: SourceSpan::new("", 3, 4), + }); + let fake_lhs = dag.alloc_port_with_shape(TypeShape::new(fake_algebra_money_usd)); + let fake_rhs = dag.alloc_port_with_shape(TypeShape::new(fake_algebra_money_usd)); + let fake_output = dag.push_transform( + TransformTarget::Operator(OperatorKind::Arithmetic(ArithmeticOp::Add)), + vec![fake_lhs, fake_rhs], + SourceSpan::new("", 3, 4), + ); + let accept_eur = dag.alloc_declaration_id(); + dag.push_declaration(Declaration { + id: accept_eur, + name: Some("accept_eur".to_string()), + connective: TypeConnective::Arrow { + inputs: vec![money_eur], + output: money_eur, + body: ArrowBody::NoBody, + }, + type_params: Vec::new(), + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: None, + span: SourceSpan::new("", 3, 4), + }); + let callable_lhs = dag.alloc_port_with_shape(TypeShape::new(money_usd)); + let callable_output = dag.push_transform( + TransformTarget::Callable(accept_eur), + vec![callable_lhs], + SourceSpan::new("", 3, 4), + ); + let generic_accept_money = dag.alloc_declaration_id(); + dag.push_declaration(Declaration { + id: generic_accept_money, + name: Some("accept_money".to_string()), + connective: TypeConnective::Arrow { + inputs: vec![money_c], + output: money_c, + body: ArrowBody::NoBody, + }, + type_params: vec![c_param], + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: None, + span: SourceSpan::new("", 4, 5), + }); + let generic_callable_lhs = dag.alloc_port_with_shape(TypeShape::new(money_usd)); + let generic_callable_output = dag.push_transform( + TransformTarget::Callable(generic_accept_money), + vec![generic_callable_lhs], + SourceSpan::new("", 4, 5), + ); + let untracked_param = dag.alloc_declaration_id(); + dag.push_declaration(Declaration { + id: untracked_param, + name: None, + connective: TypeConnective::Atom(AtomPayload::TypeParam("U".to_string())), + type_params: Vec::new(), + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: None, + span: SourceSpan::new("", 4, 5), + }); + let untracked_money = dag.alloc_declaration_id(); + dag.push_declaration(Declaration { + id: untracked_money, + name: Some("UntrackedMoney".to_string()), + connective: TypeConnective::Conj { + children: vec![Field { + label: "amount".to_string(), + ty: int, + }], + }, + type_params: vec![untracked_param], + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: None, + span: SourceSpan::new("", 4, 5), + }); + let untracked_money_usd = dag.alloc_declaration_id(); + dag.push_declaration(Declaration { + id: untracked_money_usd, + name: Some("UntrackedMoneyUSD".to_string()), + connective: TypeConnective::Instantiation { + template: untracked_money, + arguments: vec![TemplateArgument { + parameter: untracked_param, + value: usd, + }], + }, + type_params: Vec::new(), + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: None, + span: SourceSpan::new("", 4, 5), + }); + let untracked_callable_lhs = dag.alloc_port_with_shape(TypeShape::new(untracked_money_usd)); + let untracked_callable_output = dag.push_transform( + TransformTarget::Callable(accept_eur), + vec![untracked_callable_lhs], + SourceSpan::new("", 4, 5), + ); + assert!( + !type_shapes_equivalent( + &dag, + &TypeShape::new(untracked_money_usd), + &TypeShape::new(money_eur), + ), + "structural equivalence must preserve phantom axes from both templates" + ); + + infer(&mut dag); + + assert_eq!( + dag.port(ok_output).state(), + &PortState::Resolved(TypeShape::new(money_usd)) + ); + let bad_diag = dag + .diagnostics() + .get(bad_output) + .expect("mismatched phantom unit should fail on operator output"); + assert!( + matches!( + bad_diag, + Diagnostic::UnitMismatch { + parameter, + expected, + actual, + .. + } if parameter == "C" + && expected.declaration == usd + && actual.declaration == eur + ), + "unexpected diagnostic: {bad_diag:?}" + ); + let cross_template_bad_diag = dag + .diagnostics() + .get(cross_template_bad_output) + .expect("mapped phantom units should fail across structurally equivalent templates"); + assert!( + matches!( + cross_template_bad_diag, + Diagnostic::UnitMismatch { + parameter, + expected, + actual, + .. + } if parameter == "C" + && expected.declaration == usd + && actual.declaration == eur + ), + "unexpected diagnostic: {cross_template_bad_diag:?}" + ); + let malformed_diag = dag + .diagnostics() + .get(malformed_output) + .expect("missing phantom instantiation argument should fail closed"); + assert!( + matches!( + malformed_diag, + Diagnostic::ResolveError { name, .. } + if name.contains("missing expected argument for phantom parameter C") + ), + "unexpected diagnostic: {malformed_diag:?}" + ); + let invalid_diag = dag + .diagnostics() + .get(invalid_output) + .expect("phantom parameter outside type_params should fail closed"); + assert!( + matches!( + invalid_diag, + Diagnostic::ResolveError { name, .. } + if name.contains("parameter is not in type_params") + ), + "unexpected diagnostic: {invalid_diag:?}" + ); + let unsupported_diag = dag + .diagnostics() + .get(unsupported_output) + .expect("unsupported phantom algebra should fail closed"); + assert!( + matches!( + unsupported_diag, + Diagnostic::ResolveError { name, .. } + if name.contains("unsupported phantom algebra CurrencyMonoid") + ), + "unexpected diagnostic: {unsupported_diag:?}" + ); + let fake_diag = dag + .diagnostics() + .get(fake_output) + .expect("non-canonical declaration named AbelianGroup should fail closed"); + assert!( + matches!( + fake_diag, + Diagnostic::ResolveError { name, .. } + if name.contains("unsupported phantom algebra AbelianGroup") + ), + "unexpected diagnostic: {fake_diag:?}" + ); + let callable_diag = dag + .diagnostics() + .get(callable_output) + .expect("callable boundary should enforce phantom units"); + assert!( + matches!( + callable_diag, + Diagnostic::UnitMismatch { + operator, + parameter, + expected, + actual, + .. + } if operator == "accept_eur" + && parameter == "C" + && expected.declaration == eur + && actual.declaration == usd + ), + "unexpected diagnostic: {callable_diag:?}" + ); + assert!( + matches!( + dag.port(generic_callable_output).state(), + PortState::Resolved(_) + ), + "generic callable should not fail phantom unit checking before C can bind" + ); + assert!( + !matches!( + dag.diagnostics().get(generic_callable_output), + Some(Diagnostic::UnitMismatch { .. }) + ), + "generic callable must not emit UnitMismatch before C can bind" + ); + let untracked_callable_diag = dag + .diagnostics() + .get(untracked_callable_output) + .expect("structural wrapper without phantom axis should not satisfy phantom wrapper"); + assert!( + matches!( + untracked_callable_diag, + Diagnostic::TypeMismatch { .. } | Diagnostic::ResolveError { .. } + ), + "unexpected diagnostic: {untracked_callable_diag:?}" + ); + } + + #[test] + fn template_argument_reconciliation_helpers_match_and_update_consistently() { + let dag = Dag::new(); + let p0 = dag.bool_shape().expect("bootstrap Bool").declaration; + let p1 = dag.int_shape().expect("bootstrap Int").declaration; + let v0 = dag.string_shape().expect("bootstrap String").declaration; + let v1 = dag.bool_shape().expect("bootstrap Bool").declaration; + let mut arguments = vec![ + TemplateArgument { + parameter: p0, + value: p0, + }, + TemplateArgument { + parameter: p1, + value: v1, + }, + ]; + + assert!(push_template_argument_binding(&mut arguments, p0, v0)); + assert!(matches!( + generated_template_arguments_match( + &arguments, + &[ + TemplateArgument { + parameter: p0, + value: v0, + }, + TemplateArgument { + parameter: p1, + value: v1, + }, + ] + ), + TemplateArgumentsMatch::Match, + )); + assert!(!push_template_argument_binding(&mut arguments, p1, v0)); + } + + #[test] + fn template_arguments_match_generated_projection_agrees_across_length_and_value_cases() { + let dag = Dag::new(); + let p0 = dag.bool_shape().expect("bootstrap Bool").declaration; + let p1 = dag.int_shape().expect("bootstrap Int").declaration; + let v0 = dag.string_shape().expect("bootstrap String").declaration; + let v1 = dag.bool_shape().expect("bootstrap Bool").declaration; + let pair = |parameter, value| TemplateArgument { parameter, value }; + let is_match = |lhs: &[TemplateArgument], rhs: &[TemplateArgument]| -> bool { + matches!( + generated_template_arguments_match(lhs, rhs), + TemplateArgumentsMatch::Match, + ) + }; + + let empty: Vec = vec![]; + assert!(is_match(&empty, &empty)); + assert!(!is_match(&empty, &[pair(p0, v0)])); + assert!(!is_match(&[pair(p0, v0)], &empty)); + + let two = vec![pair(p0, v0), pair(p1, v1)]; + assert!(is_match(&two, &two.clone())); + assert!(!is_match(&two, &[pair(p0, v0), pair(p1, v0)])); + assert!(!is_match(&two, &[pair(p0, v0), pair(p0, v1)])); + } + + #[test] + fn filter_non_self_template_arguments_matches_handwritten_filter() { + let dag = Dag::new(); + let p0 = dag.bool_shape().expect("bootstrap Bool").declaration; + let p1 = dag.int_shape().expect("bootstrap Int").declaration; + let args = vec![ + TemplateArgument { + parameter: p0, + value: p0, + }, + TemplateArgument { + parameter: p1, + value: p0, + }, + TemplateArgument { + parameter: p1, + value: p1, + }, + ]; + let expected: Vec = args + .iter() + .filter(|a| a.parameter != a.value) + .cloned() + .collect(); + let actual = filter_non_self_template_arguments(&args); + assert_eq!(actual.len(), expected.len()); + for (a, e) in actual.iter().zip(expected.iter()) { + assert_eq!(a.parameter, e.parameter); + assert_eq!(a.value, e.value); + } + } + + /// SG-4b (callable-instantiation normalization cluster): exercise + /// `find_equivalent_anonymous_instantiation` through the real + /// declaration-table path so the generated template-argument + /// comparison is the one doing the dedup. + #[test] + fn find_equivalent_anonymous_instantiation_dedups_via_generated_comparison() { + let mut dag = Dag::new(); + let template = dag.bool_shape().expect("bootstrap Bool").declaration; + let p0 = dag.int_shape().expect("bootstrap Int").declaration; + let p1 = dag.string_shape().expect("bootstrap String").declaration; + let v0 = dag.int_shape().expect("bootstrap Int").declaration; + let v1 = dag.bool_shape().expect("bootstrap Bool").declaration; + + let args = vec![ + TemplateArgument { + parameter: p0, + value: v0, + }, + TemplateArgument { + parameter: p1, + value: v1, + }, + ]; + + let anon_id = dag.alloc_declaration_id(); + dag.push_declaration(Declaration { + id: anon_id, + name: None, + connective: TypeConnective::Instantiation { + template, + arguments: args.clone(), + }, + type_params: Vec::new(), + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: None, + span: synthetic_span(), + }); + + // Exact arguments match → dedup hit. + assert_eq!( + find_equivalent_anonymous_instantiation(&dag, template, &args, None), + Some(anon_id), + ); + + // One value differs → no hit. + let mut value_diff = args.clone(); + value_diff[1].value = v0; + assert_eq!( + find_equivalent_anonymous_instantiation(&dag, template, &value_diff, None), + None, + ); + + // Length differs → no hit. + assert_eq!( + find_equivalent_anonymous_instantiation(&dag, template, &args[..1], None), + None, + ); + + let opaque = NominalOpacity { + permitted_accessors: Vec::new(), + }; + assert_eq!( + find_equivalent_anonymous_instantiation(&dag, template, &args, Some(&opaque)), + None, + "anonymous instantiation interning must not collapse distinct nominal-opacity carriers" + ); + + let opaque_anon_id = dag.alloc_declaration_id(); + dag.push_declaration(Declaration { + id: opaque_anon_id, + name: None, + connective: TypeConnective::Instantiation { + template, + arguments: args.clone(), + }, + type_params: Vec::new(), + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: Some(opaque.clone()), + span: synthetic_span(), + }); + assert_eq!( + find_equivalent_anonymous_instantiation(&dag, template, &args, Some(&opaque)), + Some(opaque_anon_id), + ); + let opaque_with_accessor = NominalOpacity { + permitted_accessors: vec![p0], + }; + assert_eq!( + find_equivalent_anonymous_instantiation( + &dag, + template, + &args, + Some(&opaque_with_accessor), + ), + None, + "permitted-accessor changes are part of the nominal-opacity interning key" + ); + + // Self-binding normalization: a `parameter == value` entry is a + // real structural distinction at this call site — + // `find_equivalent_anonymous_instantiation` does not strip self + // bindings itself; `normalized_instantiation_args` owns that. + // An instantiation with a self-binding added does not dedup + // against one without it, confirming the comparison is strict + // pairwise and the generated helper's length check fires. + let mut with_self = args.clone(); + with_self.push(TemplateArgument { + parameter: p0, + value: p0, + }); + assert_eq!( + find_equivalent_anonymous_instantiation(&dag, template, &with_self, None), + None, + ); + } + + #[test] + fn arithmetic_division_uses_totalized_result_shape() { + let mut dag = Dag::new(); + let int = dag.int_shape().expect("bootstrap Int").declaration; + let lhs = TypeShape::new(int); + let first = + resolve_operator_arrow(&mut dag, OperatorKind::Arithmetic(ArithmeticOp::Div), &lhs) + .expect("division must resolve"); + let after_first = dag.declarations().len(); + let second = + resolve_operator_arrow(&mut dag, OperatorKind::Arithmetic(ArithmeticOp::Div), &lhs) + .expect("division must resolve"); + + assert_eq!(first.output.declaration, second.output.declaration); + assert_eq!( + after_first, + dag.declarations().len(), + "div resolution should dedup anonymous instantiation", + ); + + let result_shape_decl = dag.declaration(first.output.declaration); + let TypeConnective::Instantiation { + template, + arguments, + } = &result_shape_decl.connective + else { + panic!("division output must remain an instantiation"); + }; + let std_result = canonical_result_decl(&dag).expect("std Result").id; + let std_div_error = canonical_div_error_decl(&dag).expect("std DivError").id; + assert_eq!(*template, std_result); + assert_eq!(arguments.len(), 2); + assert_eq!(arguments[0].value, int); + assert_eq!(arguments[1].value, std_div_error); + } + + #[test] + fn arithmetic_division_canonical_result_and_diverror_are_structural_not_path_keyed() { + let mut dag = Dag::new(); + let int = dag.int_shape().expect("bootstrap Int").declaration; + let result = canonical_result_decl(&dag).expect("bootstrap Result").id; + let div_error = canonical_div_error_decl(&dag) + .expect("bootstrap DivError") + .id; + + dag.declaration_mut(result).span.file = "renamed/std/errors.dag".to_string(); + dag.declaration_mut(div_error).span.file = "renamed/std/errors.dag".to_string(); + + let resolved = resolve_operator_arrow( + &mut dag, + OperatorKind::Arithmetic(ArithmeticOp::Div), + &TypeShape::new(int), + ) + .expect("division must resolve through structural error primitives"); + let TypeConnective::Instantiation { + template, + arguments, + } = &dag.declaration(resolved.output.declaration).connective + else { + panic!("division output must remain an instantiation"); + }; + + assert_eq!(*template, result); + assert_eq!(arguments.len(), 2); + assert_eq!(arguments[0].value, int); + assert_eq!(arguments[1].value, div_error); + assert_eq!( + canonical_result_decl(&dag).expect("structural Result").id, + result + ); + assert_eq!( + canonical_div_error_decl(&dag) + .expect("structural DivError") + .id, + div_error + ); + } + + #[test] + fn arithmetic_division_ignores_user_shadowed_result_carriers() { + let mut dag = Dag::new(); + let int = dag.int_shape().expect("bootstrap Int").declaration; + let span = synthetic_span(); + + let shadow_ok = dag.alloc_declaration_id(); + dag.push_declaration(Declaration { + id: shadow_ok, + name: None, + connective: TypeConnective::Atom(AtomPayload::TypeParam("Ok".to_string())), + type_params: Vec::new(), + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: None, + span: span.clone(), + }); + let shadow_err = dag.alloc_declaration_id(); + dag.push_declaration(Declaration { + id: shadow_err, + name: None, + connective: TypeConnective::Atom(AtomPayload::TypeParam("Err".to_string())), + type_params: Vec::new(), + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: None, + span: span.clone(), + }); + let shadow_result = dag.alloc_declaration_id(); + dag.push_declaration(Declaration { + id: shadow_result, + name: Some("Result".to_string()), + connective: TypeConnective::Disj { + variants: vec![ + Field { + label: "Ok".to_string(), + ty: shadow_ok, + }, + Field { + label: "Err".to_string(), + ty: shadow_err, + }, + ], + }, + type_params: vec![shadow_ok, shadow_err], + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: None, + span: span.clone(), + }); + let shadow_div_error = dag.alloc_declaration_id(); + dag.push_declaration(Declaration { + id: shadow_div_error, + name: Some("DivError".to_string()), + connective: TypeConnective::Disj { + variants: vec![ + Field { + label: "DivideByZero".to_string(), + ty: int, + }, + Field { + label: "Overflow".to_string(), + ty: int, + }, + ], + }, + type_params: Vec::new(), + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: None, + span, + }); + assert!( + !substrate_div_error_type_decl_suppressed_for_emit( + &dag, + dag.declaration(shadow_div_error) + ), + "payload-bearing DivError twin must not match the canonical unit-variant fingerprint", + ); + + let resolved = resolve_operator_arrow( + &mut dag, + OperatorKind::Arithmetic(ArithmeticOp::Div), + &TypeShape::new(int), + ) + .expect("division must resolve through std carrier"); + let TypeConnective::Instantiation { + template, + arguments, + } = &dag.declaration(resolved.output.declaration).connective + else { + panic!("division output must remain an instantiation"); + }; + + assert_ne!(*template, shadow_result); + assert_ne!(arguments[1].value, shadow_div_error); + assert_eq!( + *template, + canonical_result_decl(&dag).expect("std Result").id + ); + assert_eq!( + arguments[1].value, + canonical_div_error_decl(&dag).expect("std DivError").id + ); + } + + #[test] + fn arithmetic_division_prefers_bootstrap_error_primitives_over_exact_twins() { + let mut dag = Dag::new(); + let int = dag.int_shape().expect("bootstrap Int").declaration; + let std_result = canonical_result_decl(&dag).expect("std Result").id; + let std_div_error = canonical_div_error_decl(&dag).expect("std DivError").id; + let span = synthetic_span(); + + let twin_ok = dag.alloc_declaration_id(); + dag.push_declaration(Declaration { + id: twin_ok, + name: None, + connective: TypeConnective::Atom(AtomPayload::TypeParam("ok".to_string())), + type_params: Vec::new(), + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: None, + span: span.clone(), + }); + let twin_err = dag.alloc_declaration_id(); + dag.push_declaration(Declaration { + id: twin_err, + name: None, + connective: TypeConnective::Atom(AtomPayload::TypeParam("err".to_string())), + type_params: Vec::new(), + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: None, + span: span.clone(), + }); + let twin_ok_payload = dag.alloc_declaration_id(); + dag.push_declaration(Declaration { + id: twin_ok_payload, + name: None, + connective: TypeConnective::Conj { + children: vec![Field { + label: "value".to_string(), + ty: twin_ok, + }], + }, + type_params: Vec::new(), + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: None, + span: span.clone(), + }); + let twin_err_payload = dag.alloc_declaration_id(); + dag.push_declaration(Declaration { + id: twin_err_payload, + name: None, + connective: TypeConnective::Conj { + children: vec![Field { + label: "value".to_string(), + ty: twin_err, + }], + }, + type_params: Vec::new(), + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: None, + span: span.clone(), + }); + let twin_result = dag.alloc_declaration_id(); + dag.push_declaration(Declaration { + id: twin_result, + name: Some("Result".to_string()), + connective: TypeConnective::Disj { + variants: vec![ + Field { + label: "Ok".to_string(), + ty: twin_ok_payload, + }, + Field { + label: "Err".to_string(), + ty: twin_err_payload, + }, + ], + }, + type_params: vec![twin_ok, twin_err], + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: None, + span: span.clone(), + }); + let twin_divide_by_zero = dag.alloc_declaration_id(); + dag.push_declaration(Declaration { + id: twin_divide_by_zero, + name: None, + connective: TypeConnective::Conj { children: vec![] }, + type_params: Vec::new(), + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: None, + span: span.clone(), + }); + let twin_overflow = dag.alloc_declaration_id(); + dag.push_declaration(Declaration { + id: twin_overflow, + name: None, + connective: TypeConnective::Conj { children: vec![] }, + type_params: Vec::new(), + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: None, + span: span.clone(), + }); + let twin_div_error = dag.alloc_declaration_id(); + dag.push_declaration(Declaration { + id: twin_div_error, + name: Some("DivError".to_string()), + connective: TypeConnective::Disj { + variants: vec![ + Field { + label: "DivideByZero".to_string(), + ty: twin_divide_by_zero, + }, + Field { + label: "Overflow".to_string(), + ty: twin_overflow, + }, + ], + }, + type_params: Vec::new(), + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: None, + span, + }); + + assert!(substrate_result_type_decl_suppressed_for_emit( + &dag, + dag.declaration(twin_result) + )); + assert!(substrate_div_error_type_decl_suppressed_for_emit( + &dag, + dag.declaration(twin_div_error) + )); + assert_eq!(canonical_result_decl(&dag).expect("Result").id, std_result); + assert_eq!( + canonical_div_error_decl(&dag).expect("DivError").id, + std_div_error + ); + + let resolved = resolve_operator_arrow( + &mut dag, + OperatorKind::Arithmetic(ArithmeticOp::Div), + &TypeShape::new(int), + ) + .expect("division must resolve through bootstrap error primitives"); + let TypeConnective::Instantiation { + template, + arguments, + } = &dag.declaration(resolved.output.declaration).connective + else { + panic!("division output must remain an instantiation"); + }; + assert_eq!(*template, std_result); + assert_eq!(arguments[1].value, std_div_error); + assert_ne!(*template, twin_result); + assert_ne!(arguments[1].value, twin_div_error); + } + + #[test] + fn read_algebra_field_fails_closed_on_unresolved_instantiation_argument() { + let mut dag = Dag::new(); + let span = synthetic_span(); + let int = dag.int_shape().expect("bootstrap Int").declaration; + + let receiver_param = dag.alloc_declaration_id(); + dag.push_declaration(Declaration { + id: receiver_param, + name: None, + connective: TypeConnective::Atom(AtomPayload::TypeParam("Self".to_string())), + type_params: Vec::new(), + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: None, + span: span.clone(), + }); + + let unresolved = dag.alloc_declaration_id(); + dag.push_declaration(Declaration { + id: unresolved, + name: None, + connective: TypeConnective::Atom(AtomPayload::UnresolvedIdentifier( + "Missing".to_string(), + )), + type_params: Vec::new(), + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: None, + span: span.clone(), + }); + + let arithmetic_field = dag.alloc_declaration_id(); + dag.push_declaration(Declaration { + id: arithmetic_field, + name: None, + connective: TypeConnective::Arrow { + inputs: vec![receiver_param, unresolved], + output: int, + body: ArrowBody::NoBody, + }, + type_params: Vec::new(), + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: None, + span: span.clone(), + }); + + let algebra = dag.alloc_declaration_id(); + dag.push_declaration(Declaration { + id: algebra, + name: Some("DivProbeAlgebra".to_string()), + connective: TypeConnective::Conj { + children: vec![Field { + label: "div".to_string(), + ty: arithmetic_field, + }], + }, + type_params: vec![receiver_param], + phantom_params: Vec::new(), + meta_tag: None, + specialization_parent: None, + inhabits: None, + value_body: None, + refinement: None, + nominal_opacity: None, + span: span.clone(), + }); + + let before = dag.declarations().len(); + let algebra_decl = dag.declaration(algebra).clone(); + assert!(read_algebra_field( + &mut dag, + &algebra_decl, + arithmetic_field, + int, + OperatorKind::Arithmetic(ArithmeticOp::Div), + &TypeShape::new(int), + ) + .is_none()); + assert_eq!(dag.declarations().len(), before); + } +} diff --git a/tools/pb0_cycle4_emit_templates/pipeline_authority.rs.in b/tools/pb0_cycle4_emit_templates/pipeline_authority.rs.in new file mode 100644 index 00000000000..70f86a7f828 --- /dev/null +++ b/tools/pb0_cycle4_emit_templates/pipeline_authority.rs.in @@ -0,0 +1,261 @@ +use crate::dag::{Dag, DeclarationId, FieldValue, TypeConnective, ValueBody}; + +#[cfg(test)] +pub(crate) const PIPELINE_AUTHORITY_FILE: &str = "src/v3/compiler/pipeline.dag"; + +const PIPELINE_STAGE_BINDING_TYPE: &str = "PipelineStageBinding"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum PipelineSnapshotKind { + Surface, + Dag, + Text, +} + +#[derive(Debug, Clone)] +pub(crate) struct PipelineStageAuthority { + // Read by `bootstrap::materialize_pipeline_realizations` when feature + // `bootstrap-regen-fresh` is enabled. + #[cfg_attr(not(feature = "bootstrap-regen-fresh"), allow(dead_code))] + pub(crate) stage: DeclarationId, + pub(crate) stage_name: String, + #[cfg_attr(not(feature = "bootstrap-regen-fresh"), allow(dead_code))] + pub(crate) realization: DeclarationId, + #[cfg_attr(not(feature = "bootstrap-regen-fresh"), allow(dead_code))] + pub(crate) realization_name: String, + pub(crate) snapshot_kind: PipelineSnapshotKind, +} + +/// Pipeline stage order, read structurally from `PipelineStageBinding` +/// declarations in the Dag. The declaration order of the bindings in +/// `pipeline.dag` is the **sole runtime ordering authority** on this path. +/// +/// **DB-16 case 2c / T-Bridge-Retirement disposition (PB review, PR #1171, +/// 2026-04-29):** `fn compile { … }` still lowers to `ArrowBody::Unparsed` — the +/// lowered Dag does **not** carry an ordered list of stage names inside the +/// compile arrow. Without that structural witness, a fail-closed cross-check +/// against the human-readable `compile` body cannot use the substrate query +/// surface alone. Prior attempts used either `include_str!("../pipeline.dag")` +/// (compile-time embed) or `std::fs::read_to_string` + span slicing (runtime +/// source-text side channel); both are rejected for `bridge_include_str_side_channels_retired` +/// in this lane. **Compile-body vs binding drift detection is therefore +/// suspended** until derivation provides a single authored carrier or a +/// lowered structural compile-body representation. Callers must keep +/// `PipelineStageBinding` declaration order and the `compile` orchestrator body +/// in sync by review/regen discipline until then. +pub(crate) fn ordered_pipeline_stages(dag: &Dag) -> Result, String> { + let binding_type_id = dag + .declaration_by_name(PIPELINE_STAGE_BINDING_TYPE) + .map(|decl| decl.id) + .ok_or_else(|| { + format!("missing pipeline authority type `{PIPELINE_STAGE_BINDING_TYPE}`") + })?; + + let mut ordered: Vec = Vec::new(); + let mut seen_stages: Vec = Vec::new(); + + for declaration in dag.declarations() { + if declaration.meta_tag != Some(binding_type_id) { + continue; + } + let Some(ValueBody::Structural { fields }) = &declaration.value_body else { + let binding_name = declaration + .name + .as_deref() + .unwrap_or(""); + return Err(format!( + "pipeline stage binding `{binding_name}` must carry a structural value body" + )); + }; + + let stage = require_decl_ref(fields, "stage", declaration.name.as_deref())?; + let realization = require_decl_ref(fields, "realization", declaration.name.as_deref())?; + let snapshot_kind = require_snapshot_kind(dag, fields, declaration.name.as_deref())?; + + let stage_name = dag.declaration(stage).name.clone().ok_or_else(|| { + format!( + "pipeline stage binding `{}` points at unnamed stage declaration `{}`", + declaration + .name + .as_deref() + .unwrap_or(""), + stage.raw() + ) + })?; + let realization_name = dag.declaration(realization).name.clone().ok_or_else(|| { + format!( + "pipeline stage binding `{}` points at unnamed realization declaration `{}`", + declaration + .name + .as_deref() + .unwrap_or(""), + realization.raw() + ) + })?; + + if seen_stages.contains(&stage) { + return Err(format!( + "pipeline stage `{stage_name}` has multiple stage bindings" + )); + } + seen_stages.push(stage); + + ordered.push(PipelineStageAuthority { + stage, + stage_name, + realization, + realization_name, + snapshot_kind, + }); + } + + if ordered.is_empty() { + return Err("pipeline authority contains no stage bindings".to_string()); + } + + Ok(ordered) +} + +/// Pipeline stage names in authority order. Thin wrapper over +/// `ordered_pipeline_stages` for callers that only need the names. +pub(crate) fn pipeline_compile_order_names(dag: &Dag) -> Result, String> { + Ok(ordered_pipeline_stages(dag)? + .into_iter() + .map(|stage| stage.stage_name) + .collect()) +} + +fn require_decl_ref( + fields: &[(String, FieldValue)], + label: &str, + binding_name: Option<&str>, +) -> Result { + fields + .iter() + .find(|(field, _)| field == label) + .and_then(|(_, value)| match value { + FieldValue::Reference(id) => Some(*id), + _ => None, + }) + .ok_or_else(|| { + format!( + "pipeline stage binding `{}` is missing required DeclarationRef field `{label}`", + binding_name.unwrap_or("") + ) + }) +} + +fn require_snapshot_kind( + dag: &Dag, + fields: &[(String, FieldValue)], + binding_name: Option<&str>, +) -> Result { + let snapshot_type_id = dag + .declaration_by_name("PipelineSnapshotKind") + .map(|decl| decl.id) + .ok_or_else(|| "missing pipeline authority type `PipelineSnapshotKind`".to_string())?; + let value = fields + .iter() + .find(|(field, _)| field == "snapshot") + .map(|(_, value)| value) + .ok_or_else(|| { + format!( + "pipeline stage binding `{}` is missing required `snapshot` field", + binding_name.unwrap_or("") + ) + })?; + + let constructor = match value { + FieldValue::Variant { + constructor, + payload, + } => { + if !payload.is_empty() { + return Err(format!( + "pipeline stage binding `{}` has non-empty snapshot payload", + binding_name.unwrap_or("") + )); + } + *constructor + } + _ => { + return Err(format!( + "pipeline stage binding `{}` must use a PipelineSnapshotKind variant for `snapshot`", + binding_name.unwrap_or("") + )) + } + }; + + let TypeConnective::Disj { variants } = &dag.declaration(snapshot_type_id).connective else { + return Err( + "pipeline authority type `PipelineSnapshotKind` must lower to a sum".to_string(), + ); + }; + let label = variants + .iter() + .find(|variant| variant.ty == constructor) + .map(|variant| variant.label.as_str()) + .ok_or_else(|| { + format!( + "pipeline stage binding `{}` uses snapshot constructor `{}` that does not belong to `PipelineSnapshotKind`", + binding_name.unwrap_or(""), + constructor.raw() + ) + })?; + + match label { + "SnapshotSurface" => Ok(PipelineSnapshotKind::Surface), + "SnapshotDag" => Ok(PipelineSnapshotKind::Dag), + "SnapshotText" => Ok(PipelineSnapshotKind::Text), + other => Err(format!( + "pipeline stage binding `{}` uses unknown snapshot constructor `{other}`", + binding_name.unwrap_or("") + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dag::ArrowBody; + + const PIPELINE_COMPILE_FN: &str = "compile"; + + #[test] + fn ordered_pipeline_stages_authority_is_pipeline_stage_binding_only() { + let dag = Dag::new(); + let names = pipeline_compile_order_names(&dag).expect("bootstrap pipeline stages"); + assert_eq!( + names, + vec![ + "parse".to_string(), + "lower".to_string(), + "infer".to_string(), + "compute_ownership".to_string(), + "lens_complexity".to_string(), + "emit".to_string(), + ], + "ordering is structural declaration order of PipelineStageBinding rows; update this test when pipeline.dag stage set changes" + ); + } + + #[test] + fn pipeline_compile_body_remains_unparsed_blocking_structural_retirement() { + let dag = Dag::new(); + let compile = dag + .declarations() + .iter() + .find(|decl| { + decl.name.as_deref() == Some(PIPELINE_COMPILE_FN) + && decl.span.file == PIPELINE_AUTHORITY_FILE + }) + .expect("pipeline `compile` declaration"); + let TypeConnective::Arrow { body, .. } = &compile.connective else { + panic!("`compile` must be an arrow declaration"); + }; + assert!( + matches!(body, ArrowBody::Unparsed(_)), + "case 2c: `compile` has no lowered ordered stage list — R3 bridge_include_str_side_channels_retired cannot be satisfied here without a new substrate fact; see module doc on ordered_pipeline_stages" + ); + } +} diff --git a/tools/pb0_cycle4_emit_templates/post_emit_verifier.rs.in b/tools/pb0_cycle4_emit_templates/post_emit_verifier.rs.in new file mode 100644 index 00000000000..463c627193d --- /dev/null +++ b/tools/pb0_cycle4_emit_templates/post_emit_verifier.rs.in @@ -0,0 +1,463 @@ +//! E-5 / Lane 1 Stage 1c PR 4 — shared test harness that reads each +//! target's `CleanEmissionContract.post_emit_verifier` record and +//! invokes the declared verifier against an emitted source file. +//! +//! Before this module, each pilot roundtrip test (`emit_rust_…`, +//! `emit_go_…`, `emit_python_…`) hardcoded the verifier command + +//! args in Rust. That divergence is exactly what the contract is +//! supposed to eliminate — the spec file declares the verifier +//! config; the harness reads it; nothing drifts. Adding a new target +//! (Swift, Kotlin, Verilog) requires only a `CleanEmissionContract` +//! data item, not a new Rust roundtrip helper. +//! +//! The harness is deliberately minimal: +//! - `parse_post_emit_verifier` walks the declared structural fields +//! of a `python_clean_emission` / `rust_clean_emission` / +//! `go_clean_emission` declaration and extracts the four +//! consumed fields (`command`, `args`, `expected_exit_code`, +//! `output_policy`) of the `PostEmitVerifier` record into a typed +//! binding. (`syntax_only` is authored on the spec but not yet +//! consumed by any Rust-side consumer — see +//! `PostEmitVerifierBinding` doc.) +//! - `run_post_emit_verifier` invokes `Command::new(binding.command) +//! .args(&binding.args).arg(source_path)`, collects stdout/stderr, +//! and applies the binding's `expected_exit_code` + +//! `output_policy` as the verdict. +//! +//! Scope caveat — calling convention: the harness assumes the +//! verifier takes the source file as its final positional argument. +//! That shape fits every target declared today (`rustc `, +//! `gofmt -l `, `python3 -m py_compile `), but the +//! `PostEmitVerifier` record does not yet encode argument-placement +//! structurally. A verifier that wants stdin, a fixed `-o` slot, or +//! a non-terminal source-path position cannot be expressed without +//! extending the contract shape. When such a target concretely +//! arrives, the fix is a structural extension to `PostEmitVerifier` +//! (e.g. `source_position: SourcePositionPolicy`), not a bespoke +//! Rust branch inside this runner — same discipline as +//! `pattern_bindings` / `output_policy`. + +use std::path::Path; +use std::process::Command; + +use crate::dag::{DeclarationId, FieldValue, LiteralBits}; +use crate::Dag; + +/// Typed read of `data _clean_emission.post_emit_verifier`. +/// Every field here is a structural declaration on the contract that +/// the runner actively consults — no defaults, no fallbacks; a +/// missing or malformed field fires `VerifierParseError::MalformedSpec` +/// at parse time so the spec-file drift surfaces loudly rather than +/// silently hand the runner a partially-filled binding. +/// +/// The authored `PostEmitVerifier` record has a fifth field, +/// `syntax_only: Bool`, which the current runner does not consume — +/// it is metadata about the verifier's depth (shallow syntax check +/// vs full compile) that downstream schedulers (per-PR CI vs +/// nightly) will dispatch on. Per E-6, a spec field lands only with +/// a same-PR consumer; this binding therefore does NOT parse +/// `syntax_only` yet. The field remains in `std/clean_emission.dag` +/// because the three existing target spec files already declare it +/// (landed in PRs 1/2/3 alongside the rest of the contract shape); +/// a follow-up PR that adds the first downstream consumer will +/// add the parse + storage here atomically. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PostEmitVerifierBinding { + pub command: String, + pub args: Vec, + pub expected_exit_code: i64, + pub output_policy: VerifierOutputPolicyBinding, +} + +/// Typed mirror of `std.clean_emission.VerifierOutputPolicy`. Only +/// closed variants — adding a new policy is a spec extension, not a +/// runner override. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VerifierOutputPolicyBinding { + IgnoreVerifierOutput, + RequireEmptyStdout, + RequireEmptyStderr, + RequireEmptyStdoutAndStderr, +} + +/// Contract-parse failures. Callers that want to surface the +/// underlying declaration in a diagnostic propagate this error +/// directly — the `DeclarationId` points at the +/// `*_clean_emission` data item so spec-authors can find the +/// offending record. +#[derive(Debug, Clone)] +pub enum VerifierParseError { + MissingDeclaration, + MalformedSpec { + declaration: DeclarationId, + detail: &'static str, + }, +} + +/// Runner failures. `InvocationFailed` fires when the OS cannot +/// spawn the verifier process (binary missing from PATH, etc.); +/// `WrongExitCode` and `PolicyViolation` fire after a successful +/// spawn when the verdict does not match the contract. +#[derive(Debug)] +pub enum VerifierRunError { + InvocationFailed { + command: String, + io_error: String, + }, + WrongExitCode { + expected: i64, + actual: Option, + stdout: String, + stderr: String, + }, + PolicyViolation { + policy: VerifierOutputPolicyBinding, + stdout: String, + stderr: String, + }, +} + +/// Parse `.post_emit_verifier` into a typed +/// binding. `spec_declaration` is the id of a +/// `*_clean_emission: CleanEmissionContract` data item (obtained via +/// `dag.rust_clean_emission_spec()` / `dag.go_clean_emission_spec()` +/// / `dag.python_clean_emission_spec()`). +pub fn parse_post_emit_verifier( + dag: &Dag, + spec_declaration: DeclarationId, +) -> Result { + let decl = dag.declaration(spec_declaration); + let Some(crate::dag::ValueBody::Structural { fields }) = &decl.value_body else { + return Err(VerifierParseError::MalformedSpec { + declaration: spec_declaration, + detail: "clean_emission spec must be a structural data item", + }); + }; + let verifier_value = fields + .iter() + .find(|(label, _)| label == "post_emit_verifier") + .map(|(_, value)| value) + .ok_or(VerifierParseError::MalformedSpec { + declaration: spec_declaration, + detail: "clean_emission is missing required `post_emit_verifier` field", + })?; + let FieldValue::Record(verifier_fields) = verifier_value else { + return Err(VerifierParseError::MalformedSpec { + declaration: spec_declaration, + detail: "clean_emission.post_emit_verifier must be a structural record", + }); + }; + + let command = require_string(verifier_fields, "command", spec_declaration)?; + let args = require_string_list(verifier_fields, "args", spec_declaration)?; + let expected_exit_code = require_int(verifier_fields, "expected_exit_code", spec_declaration)?; + let output_policy = + parse_output_policy(dag, verifier_fields, "output_policy", spec_declaration)?; + + Ok(PostEmitVerifierBinding { + command, + args, + expected_exit_code, + output_policy, + }) +} + +/// Invoke the verifier against `source_path` and apply the +/// contract's verdict semantics. The source file path is appended +/// as the final positional argument — every declared verifier +/// today expects the file as its last argument +/// (`rustc ` / `gofmt -l ` / `python3 -m py_compile +/// `). +pub fn run_post_emit_verifier( + binding: &PostEmitVerifierBinding, + source_path: &Path, +) -> Result<(), VerifierRunError> { + let mut command = Command::new(&binding.command); + command.args(&binding.args).arg(source_path); + // Rustc (and py_compile to a lesser extent) produce artifacts + // next to / in cwd. Pinning cwd to the source's parent directory + // contains those artifacts inside the caller's tmp dir instead + // of dropping them into the workspace root. + // + // `Path::parent()` returns `Some("")` for single-component + // relative paths (e.g. `main.rs`); `current_dir("")` fails the + // spawn on most platforms. Filter empties so relative paths in + // the current directory fall through to the inherited cwd + // instead of rejecting valid inputs at spawn time. + if let Some(parent) = source_path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + command.current_dir(parent); + } + let output = command + .output() + .map_err(|err| VerifierRunError::InvocationFailed { + command: binding.command.clone(), + io_error: err.to_string(), + })?; + + let exit_code = output.status.code(); + let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + + if exit_code.map(i64::from) != Some(binding.expected_exit_code) { + return Err(VerifierRunError::WrongExitCode { + expected: binding.expected_exit_code, + actual: exit_code, + stdout, + stderr, + }); + } + + let policy = binding.output_policy; + let stdout_empty = stdout.trim().is_empty(); + let stderr_empty = stderr.trim().is_empty(); + let ok = match policy { + VerifierOutputPolicyBinding::IgnoreVerifierOutput => true, + VerifierOutputPolicyBinding::RequireEmptyStdout => stdout_empty, + VerifierOutputPolicyBinding::RequireEmptyStderr => stderr_empty, + VerifierOutputPolicyBinding::RequireEmptyStdoutAndStderr => stdout_empty && stderr_empty, + }; + if !ok { + return Err(VerifierRunError::PolicyViolation { + policy, + stdout, + stderr, + }); + } + Ok(()) +} + +fn require_string( + fields: &[(String, FieldValue)], + name: &'static str, + declaration: DeclarationId, +) -> Result { + fields + .iter() + .find(|(label, _)| label == name) + .and_then(|(_, value)| match value { + FieldValue::Literal(LiteralBits::String(s)) => Some(s.clone()), + _ => None, + }) + .ok_or(VerifierParseError::MalformedSpec { + declaration, + detail: "required string field missing or malformed", + }) +} + +fn require_int( + fields: &[(String, FieldValue)], + name: &'static str, + declaration: DeclarationId, +) -> Result { + fields + .iter() + .find(|(label, _)| label == name) + .and_then(|(_, value)| match value { + FieldValue::Literal(LiteralBits::Int(s)) => s.parse::().ok(), + _ => None, + }) + .ok_or(VerifierParseError::MalformedSpec { + declaration, + detail: "required int field missing or malformed", + }) +} + +fn require_string_list( + fields: &[(String, FieldValue)], + name: &'static str, + declaration: DeclarationId, +) -> Result, VerifierParseError> { + let value = fields + .iter() + .find(|(label, _)| label == name) + .map(|(_, value)| value) + .ok_or(VerifierParseError::MalformedSpec { + declaration, + detail: "required list field missing", + })?; + let FieldValue::List(entries) = value else { + return Err(VerifierParseError::MalformedSpec { + declaration, + detail: "required field must be a list", + }); + }; + let mut out = Vec::with_capacity(entries.len()); + for entry in entries { + let FieldValue::Literal(LiteralBits::String(s)) = entry else { + return Err(VerifierParseError::MalformedSpec { + declaration, + detail: "list entries must be string literals", + }); + }; + out.push(s.clone()); + } + Ok(out) +} + +fn parse_output_policy( + dag: &Dag, + fields: &[(String, FieldValue)], + name: &'static str, + declaration: DeclarationId, +) -> Result { + let value = fields + .iter() + .find(|(label, _)| label == name) + .map(|(_, value)| value) + .ok_or(VerifierParseError::MalformedSpec { + declaration, + detail: "required variant field missing", + })?; + let FieldValue::Variant { + constructor, + payload, + } = value + else { + return Err(VerifierParseError::MalformedSpec { + declaration, + detail: "output_policy must be a VerifierOutputPolicy variant", + }); + }; + if !payload.is_empty() { + return Err(VerifierParseError::MalformedSpec { + declaration, + detail: "VerifierOutputPolicy variants must not carry payload fields", + }); + } + // Variant resolution dispatches on the cached + // `VerifierOutputPolicyVariants` typed handles — the Dag + // resolves the Disj's variant ids once at bootstrap end and + // every downstream consumer compares `DeclarationId`s here. No + // variant.label string lookups at parse time; INVARIANTS.md + // §Layer opacity / §Semantic authority forbids post-lowering + // consumers from reaching for names. Mirrors the PR 2.5 + // `PatternBindingRuleVariants` structure. + let variants = dag.verifier_output_policy_variants(); + let ignore_output = variants + .ignore_output + .ok_or(VerifierParseError::MalformedSpec { + declaration, + detail: "VerifierOutputPolicy.IgnoreVerifierOutput declaration was not found", + })?; + let require_empty_stdout = + variants + .require_empty_stdout + .ok_or(VerifierParseError::MalformedSpec { + declaration, + detail: "VerifierOutputPolicy.RequireEmptyStdout declaration was not found", + })?; + let require_empty_stderr = + variants + .require_empty_stderr + .ok_or(VerifierParseError::MalformedSpec { + declaration, + detail: "VerifierOutputPolicy.RequireEmptyStderr declaration was not found", + })?; + let require_empty_stdout_and_stderr = + variants + .require_empty_stdout_and_stderr + .ok_or(VerifierParseError::MalformedSpec { + declaration, + detail: + "VerifierOutputPolicy.RequireEmptyStdoutAndStderr declaration was not found", + })?; + if *constructor == ignore_output { + Ok(VerifierOutputPolicyBinding::IgnoreVerifierOutput) + } else if *constructor == require_empty_stdout { + Ok(VerifierOutputPolicyBinding::RequireEmptyStdout) + } else if *constructor == require_empty_stderr { + Ok(VerifierOutputPolicyBinding::RequireEmptyStderr) + } else if *constructor == require_empty_stdout_and_stderr { + Ok(VerifierOutputPolicyBinding::RequireEmptyStdoutAndStderr) + } else { + Err(VerifierParseError::MalformedSpec { + declaration, + detail: "output_policy constructor is not a known VerifierOutputPolicy variant", + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Parse the Rust contract and assert the structural fields + /// match the declared values in `src/v3/spec/rust.dag`. Proves + /// the parser consumes the shared substrate correctly without + /// hardcoding any of the target-specific command / args values. + #[test] + fn parses_rust_post_emit_verifier_contract() { + let dag = crate::dag::Dag::new(); + let spec = dag + .rust_clean_emission_spec() + .expect("rust_clean_emission cached"); + let binding = parse_post_emit_verifier(&dag, spec).expect("parse"); + assert_eq!(binding.command, "rustc"); + assert_eq!(binding.args, vec!["--edition=2021", "-D", "warnings"]); + assert_eq!(binding.expected_exit_code, 0); + assert_eq!( + binding.output_policy, + VerifierOutputPolicyBinding::IgnoreVerifierOutput + ); + } + + #[test] + fn parses_go_post_emit_verifier_contract() { + let dag = crate::dag::Dag::new(); + let spec = dag + .go_clean_emission_spec() + .expect("go_clean_emission cached"); + let binding = parse_post_emit_verifier(&dag, spec).expect("parse"); + assert_eq!(binding.command, "gofmt"); + assert_eq!(binding.args, vec!["-l"]); + assert_eq!(binding.expected_exit_code, 0); + assert_eq!( + binding.output_policy, + VerifierOutputPolicyBinding::RequireEmptyStdout + ); + } + + #[test] + fn parses_python_post_emit_verifier_contract() { + let dag = crate::dag::Dag::new(); + let spec = dag + .python_clean_emission_spec() + .expect("python_clean_emission cached"); + let binding = parse_post_emit_verifier(&dag, spec).expect("parse"); + assert_eq!(binding.command, "python3"); + assert_eq!(binding.args, vec!["-m", "py_compile"]); + assert_eq!(binding.expected_exit_code, 0); + assert_eq!( + binding.output_policy, + VerifierOutputPolicyBinding::IgnoreVerifierOutput + ); + } + + /// Regression: `Path::parent()` returns `Some("")` for + /// single-component relative paths like `main.rs`. Setting + /// `current_dir("")` fails the spawn on most platforms, so the + /// runner used to reject valid inputs whenever callers passed + /// a bare relative filename. The fix filters empty parents so + /// the spawn inherits the ambient cwd instead. This test uses + /// an environment-probe binary that exists on every + /// POSIX-shaped test runner and expects exit 0 regardless of + /// the path passed; the specific file does not need to exist + /// because the probe doesn't read it. + #[cfg(unix)] + #[test] + fn run_accepts_single_component_relative_source_path() { + use std::path::PathBuf; + let binding = PostEmitVerifierBinding { + command: "true".to_string(), + args: Vec::new(), + expected_exit_code: 0, + output_policy: VerifierOutputPolicyBinding::IgnoreVerifierOutput, + }; + let relative = PathBuf::from("main.rs"); + run_post_emit_verifier(&binding, &relative) + .expect("single-component relative source path must not fail the spawn"); + } +}