diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0f0cc6ba5b3..fe86819bd29 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -282,6 +282,8 @@ jobs: id: v4_bootstrap_compile timeout-minutes: 35 continue-on-error: true + env: + V4_BOOTSTRAP_TIMEOUT_SECS: 480 run: | set +euo pipefail bash scripts/v4-bootstrap-viability.sh @@ -289,7 +291,7 @@ jobs: echo "exitcode=${ec}" >> "$GITHUB_OUTPUT" exit "${ec}" - name: v2 → v4 bootstrap resolve-posture gate (CI emit-wall bridge) - if: needs.affected.outputs.v4 == 'true' && steps.v4_bootstrap_compile.outputs.exitcode != '0' + if: needs.affected.outputs.v4 == 'true' && (steps.v4_bootstrap_compile.outputs.exitcode == '124' || steps.v4_bootstrap_compile.outputs.exitcode == '143') id: v4_bootstrap_resolve_posture run: | set -euo pipefail @@ -313,7 +315,7 @@ jobs: - name: 'T-22 TestClaim corpus structural bridge — manual .dag corpus' if: needs.affected.outputs.v4 == 'true' || needs.affected.outputs.workflow_policy == 'true' env: - V4_TESTCLAIM_TIMEOUT_SECS: 360 + V4_TESTCLAIM_TIMEOUT_SECS: 480 run: bash scripts/v4-testclaim-corpus-gate.sh # v2 fixed-point — runs only when src/v2/ is affected. v2 is restored as # comparison artifact + v4 bootstrap source (frozen-buildable). Verifies diff --git a/Cargo.lock b/Cargo.lock index c4db15b3bcb..21ed2df08e5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -292,6 +292,10 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "emit_host_runner" +version = "0.1.0" + [[package]] name = "equivalent" version = "1.0.2" @@ -1139,6 +1143,7 @@ version = "0.1.0" dependencies = [ "ci_affected_components", "criterion", + "emit_host_runner", "gen_gunbc_ci_workflow_dag", "libc", "num-bigint", diff --git a/Cargo.toml b/Cargo.toml index 42a6f6594af..a9f23a9d2cc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "src/v3/compiler", "tools/gen_gunbc_ci_workflow_dag", "tools/ci_affected_components", + "tools/emit_host_runner", "src/v3/grounding_pilot", "src/v3/grounding_engine", "src/v3/grounding_lifetime", diff --git a/scripts/ci-merge/sg0-pr-body-append.3958.txt b/scripts/ci-merge/sg0-pr-body-append.3958.txt new file mode 100644 index 00000000000..9ba47e91c66 --- /dev/null +++ b/scripts/ci-merge/sg0-pr-body-append.3958.txt @@ -0,0 +1,3 @@ +SG-0 hand-path delta: +1 + +SG-0 pairing: (c) dispatch for P5(b) net +1 on `EXPECTED_HAND_AUTHORED_TEST` (`v4_emit_host_harness_test.rs`); concrete dispatch evidence: gunb-ai/gunbc#3958 (W2 host harness) → W3 wires `nat_semiring_rung34_runtime_value_rows` + `run_emit_host_rust` transport (dissolves `emit_host_transport_not_wired` and retires this probe). Lane cite: `_internal/ROADMAP_OPS.md` **T-PB-B** / `pb_rust_tests_outside_residual_zero`. diff --git a/src/v3/compiler/Cargo.toml b/src/v3/compiler/Cargo.toml index d5513f23e2b..7b344ca3ee5 100644 --- a/src/v3/compiler/Cargo.toml +++ b/src/v3/compiler/Cargo.toml @@ -46,6 +46,7 @@ libc = { version = "0.2", default-features = false } criterion = "0.5" gen_gunbc_ci_workflow_dag = { path = "../../../tools/gen_gunbc_ci_workflow_dag" } ci_affected_components = { path = "../../../tools/ci_affected_components" } +emit_host_runner = { path = "../../../tools/emit_host_runner" } serde = { workspace = true } serde_json = { workspace = true } # T-V2 G-1 manifest ratchet: structured Cargo.toml walk (`toml` value parse). diff --git a/src/v3/compiler/tests/integration.rs b/src/v3/compiler/tests/integration.rs index 9c6d27dc19d..c9bf862f14b 100644 --- a/src/v3/compiler/tests/integration.rs +++ b/src/v3/compiler/tests/integration.rs @@ -287,6 +287,8 @@ mod v4_compiler_compile_public_terminal_smoke_test; mod v4_compiler_emit_translate_smoke_test; #[path = "integration/v4_compiler_parse_table_dag_smoke_test.rs"] mod v4_compiler_parse_table_dag_smoke_test; +#[path = "integration/v4_emit_host_harness_test.rs"] +mod v4_emit_host_harness_test; #[path = "integration/v4_extdeps_coordination_dag_smoke_test.rs"] mod v4_extdeps_coordination_dag_smoke_test; #[path = "integration/v4_extdeps_file_system_dag_smoke_test.rs"] diff --git a/src/v3/compiler/tests/integration/sg0_census_test.rs b/src/v3/compiler/tests/integration/sg0_census_test.rs index 0e9179bfd57..a52f9977fc5 100644 --- a/src/v3/compiler/tests/integration/sg0_census_test.rs +++ b/src/v3/compiler/tests/integration/sg0_census_test.rs @@ -851,8 +851,6 @@ const EXPECTED_HAND_AUTHORED_TEST: &[&str] = &[ "src/v3/compiler/tests/integration/v2_oracle_no_remaining_test_consumers_test.rs", // T-15: tokenize/parse smoke on `src/v4/bin/main.dag` + trampoline source anchors (see harness module docs). "src/v3/compiler/tests/integration/v4_bin_main_dag_smoke_test.rs", - // T-15: bin/main.dag execution + bootstrap fixpt stage1==stage2 harness (`t_15_self_host_fixed_point`). - "src/v3/compiler/tests/integration/v4_t15_self_host_fixed_point_harness_test.rs", // Wave-5-A / P3 commitment 6: validate_then_compile public terminal on 00_compile.dag. "src/v3/compiler/tests/integration/v4_compiler_compile_public_terminal_smoke_test.rs", // T-10 / Wave-3-B: tokenize/parse smoke on `06_translate.dag`, `05_emit.dag`, MVP-1 claim. @@ -865,21 +863,24 @@ const EXPECTED_HAND_AUTHORED_TEST: &[&str] = &[ // T-7: parse-table memoization receipt (`02_parse.dag`, grammar_validation claim, ListTailResult). // SG-0 + INVARIANTS §P5(b) receipt — row `v4_compiler_parse_table_dag_smoke_test.rs` in INVARIANTS.md. "src/v3/compiler/tests/integration/v4_compiler_parse_table_dag_smoke_test.rs", + // W2 / T-38 rung-4 host harness: behavior-driven `tools/emit_host_runner` + `.dag` surface + // needles (`emit_host.dag`, `host_run.dag`, `test_claim_falsification.dag`). + // SG-0 + INVARIANTS §P5(b) receipt (+1 census path). Dissolves when W3 wires transport + // into `run_emit_host_rust` and rung-3/4 TestClaim rows replace hand-Rust probes. + "src/v3/compiler/tests/integration/v4_emit_host_harness_test.rs", + // T-4.8 coordination substrate: decomposed WireContractFacts + CoordinationBind shape, + // with WIRECONTRACT-OBLIGATION-TABLE-T4.8 per-effect obligation rows. + "src/v3/compiler/tests/integration/v4_extdeps_coordination_dag_smoke_test.rs", // Wave-2-C2 / T-4.5: extdeps/file_system.dag pure resource model (Practice 11 companion). "src/v3/compiler/tests/integration/v4_extdeps_file_system_dag_smoke_test.rs", // T-4.16: `ConfigPatchRecord` / `config_patch_layer` consumer smoke on `black.dag` // (zero diagnostics; `BlackConfigPatch` materializes `FieldPatch` fields). // SG-0 + INVARIANTS §P5(b) receipt. "src/v3/compiler/tests/integration/v4_extdeps_formatters_black_dag_smoke_test.rs", - // T-4.8 coordination substrate: decomposed WireContractFacts + CoordinationBind shape, - // with WIRECONTRACT-OBLIGATION-TABLE-T4.8 per-effect obligation rows. - "src/v3/compiler/tests/integration/v4_extdeps_coordination_dag_smoke_test.rs", // T-4.7 React framework substrate: `compile_to_dag` smoke on // `src/v4/extdeps/frameworks/react.dag` (zero module diagnostics). // SG-0 ratchet per INVARIANTS §P5(b) + typescript extdeps precedent. "src/v3/compiler/tests/integration/v4_extdeps_react_dag_smoke_test.rs", - // T-16-A+ TaskManager omni fixture: compile_to_dag smoke + SQL DDL projection receipt. - "src/v3/compiler/tests/integration/v4_test_fixture_task_manager_demo_smoke_test.rs", // T-21 IRT-1: incremental re-exec frontier (`src/v4/lens/affected_set.dag`); // `re_exec_frontier_from_diff` + mechanical reverification claim; SG-0 + INVARIANTS §P5(b). "src/v3/compiler/tests/integration/v4_lens_affected_set_dag_smoke_test.rs", @@ -898,22 +899,26 @@ const EXPECTED_HAND_AUTHORED_TEST: &[&str] = &[ // `manual_test_claim_for_manual_anchor` in `src/v4/lens/testgen.dag`; `src/v4/std/verification.dag` // for closed `TestClaim` schema only (no std present-key helper). "src/v3/compiler/tests/integration/v4_lens_testgen_dag_smoke_test.rs", + // P9 single-owner: corpus scan for `fn llvm_instruction_cost` under src/v4/ (replaces dissolved + // v4_lens_cost_dag_smoke_test.rs ratchet). SG-0 + INVARIANTS §P5(b) receipt. + "src/v3/compiler/tests/integration/v4_p9_llvm_instruction_cost_single_owner_test.rs", // T-33: parse ratchet on `src/v4/std/model_core.dag` — Ratified Q1 ModelCore carrier. // SG-0 + INVARIANTS §P5(b) receipt — row `v4_std_model_core_dag_smoke_test.rs` in INVARIANTS.md. "src/v3/compiler/tests/integration/v4_std_model_core_dag_smoke_test.rs", + // T-15: bin/main.dag execution + bootstrap fixpt stage1==stage2 harness (`t_15_self_host_fixed_point`). + "src/v3/compiler/tests/integration/v4_t15_self_host_fixed_point_harness_test.rs", // T-19/T-20 closeout ratchets over v4 testgen + bootstrap-infra parse surfaces. // SG-0 + INVARIANTS §P5(b) receipt; dissolves when the same checks are `.dag` // TestClaims or generated harness coverage. "src/v3/compiler/tests/integration/v4_test_bootstrap_infra_closeout_test.rs", + // T-16-A+ TaskManager omni fixture: compile_to_dag smoke + SQL DDL projection receipt. + "src/v3/compiler/tests/integration/v4_test_fixture_task_manager_demo_smoke_test.rs", // T-21/T-24: `workflow/ci.dag` Wave-0 selection consumes `RerunNodeSet` for TestClaim roster narrowing. // SG-0 + INVARIANTS §P5(b) receipt; dissolves when `.dag` TestClaim harness replaces probe. "src/v3/compiler/tests/integration/v4_workflow_ci_runner_dag_smoke_test.rs", // RELEASE_TODO §5 Phase 1a: `workflow/release.dag` + hand-synced release.yml (install.dag Phase 1b). // SG-0 + INVARIANTS §P5(b) receipt; dissolves when YamlStatic emission replaces host probes. "src/v3/compiler/tests/integration/v4_workflow_release_dag_smoke_test.rs", - // P9 single-owner: corpus scan for `fn llvm_instruction_cost` under src/v4/ (replaces dissolved - // v4_lens_cost_dag_smoke_test.rs ratchet). SG-0 + INVARIANTS §P5(b) receipt. - "src/v3/compiler/tests/integration/v4_p9_llvm_instruction_cost_single_owner_test.rs", // §1.8 gate #96 (`value_body_substrate_mirror_isomorphism_executable`): // CI-visible generated Rust `ValueBody` mirror vs `substrate.dag` // constructor isomorphism. Dissolves when `ValueBody` no longer has a diff --git a/src/v3/compiler/tests/integration/v4_emit_host_harness_test.rs b/src/v3/compiler/tests/integration/v4_emit_host_harness_test.rs new file mode 100644 index 00000000000..77240143893 --- /dev/null +++ b/src/v3/compiler/tests/integration/v4_emit_host_harness_test.rs @@ -0,0 +1,221 @@ +//! **Layer:** integration +//! +//! **W2 / joint spec** `compiler-spine-runtime-rung34-min-runner-interface-2026-05-30.md` §4.2: +//! emit-host harness substrate (`emit_host.dag`, `host_run.dag`, `test_claim_falsification.dag`, +//! `nat_semiring_rung34_eval.dag`) + executable boundary `tools/emit_host_runner`. +//! +//! **ROADMAP:** `ROADMAP.md` § **Nine lanes** row **T-PB-B** / `pb_rust_tests_outside_residual_zero`; +//! **TASKS.md** T-38 / rung-4 host receipt path. +//! +//! **PR receipt (P5 Mechanism (b)):** this harness + matching `EXPECTED_HAND_AUTHORED_TEST` +//! line in `sg0_census_test.rs` land in the same PR. **This PR (+1 census path):** +//! `v4_emit_host_harness_test.rs` — behavior-driven `run_emit_host_rust` (compile + run fixture, +//! `HostExit` Holds witness + five-byte stdout parse) plus **tokenize/parse** surface receipts +//! for W2 `.dag` modules (not `str::contains` source probes per TESTING.md). +//! +//! **Dissolution trigger:** W3 populates `nat_semiring_rung34_runtime_value_rows` +//! and wires `run_emit_host_rust` in `emit_host.dag` to invoke `tools/emit_host_runner` (removes +//! `emit_host_transport_not_wired` on the Rust row); delete this file when rung-3/4 claims + +//! generated harness replace hand-Rust probes (see `nat_semiring_rung34_eval.dag` roster comment). +//! +//! **TESTING.md:** substrate `.dag` eval remains hermetic (`run_emit_host_rust` → `Rejected` until +//! W3/CI wiring); this test exercises the Rust transport the `.dag` row models, not substrate eval. + +use v3_compiler::parse_for_test; +use v3_compiler::parse_surface::{SurfaceItem, SurfaceVariant}; +use v3_compiler::tokenize_for_test; + +const EMIT_HOST_DAG: &str = include_str!("../../../../v4/compiler/emit_host.dag"); +const EMIT_HOST_PATH: &str = "src/v4/compiler/emit_host.dag"; +const HOST_RUN_DAG: &str = include_str!("../../../../v4/std/host_run.dag"); +const HOST_RUN_PATH: &str = "src/v4/std/host_run.dag"; +const FALSIFICATION_DAG: &str = include_str!("../../../../v4/std/test_claim_falsification.dag"); +const FALSIFICATION_PATH: &str = "src/v4/std/test_claim_falsification.dag"; +const NAT_SEMIRING_RUNG34_EVAL_DAG: &str = + include_str!("../../../../v4/test/claim/workflow/nat_semiring_rung34_eval.dag"); +const NAT_SEMIRING_RUNG34_EVAL_PATH: &str = + "src/v4/test/claim/workflow/nat_semiring_rung34_eval.dag"; + +/// Minimal fixture: five stdout bytes (MVP runtime value `5` alignment). +const EMIT_HOST_FIXTURE_SOURCE: &str = + "fn main() { let _ = std::io::Write::write_all(&mut std::io::stdout(), &[0u8; 5]); }"; + +fn parse_module(source: &str, path: &str) -> v3_compiler::parse_surface::SurfaceModule { + let tokens = + tokenize_for_test(source, path).unwrap_or_else(|e| panic!("{path}: tokenize: {e:?}")); + parse_for_test(&tokens, path).unwrap_or_else(|e| panic!("{path}: parse: {e:?}")) +} + +fn surface_declares_fn(module: &v3_compiler::parse_surface::SurfaceModule, name: &str) -> bool { + module.items.iter().any(|item| match item { + SurfaceItem::Fn { + name: item_name, .. + } + | SurfaceItem::FnExternalBody { + name: item_name, .. + } => item_name == name, + _ => false, + }) +} + +fn surface_declares_type(module: &v3_compiler::parse_surface::SurfaceModule, name: &str) -> bool { + module.items.iter().any(|item| { + matches!( + item, + SurfaceItem::TypeSum { name: decl_name, .. } + | SurfaceItem::TypeRecord { name: decl_name, .. } + | SurfaceItem::TypeAlias { name: decl_name, .. } + | SurfaceItem::TypeAtom { name: decl_name, .. } + if decl_name == name + ) + }) +} + +fn type_record_field_names( + module: &v3_compiler::parse_surface::SurfaceModule, + type_name: &str, +) -> Vec { + module + .items + .iter() + .find_map(|item| match item { + SurfaceItem::TypeRecord { name, fields, .. } if name == type_name => Some( + fields + .iter() + .map(|field| field.name.clone()) + .collect::>(), + ), + _ => None, + }) + .unwrap_or_else(|| panic!("{type_name}: missing type record")) +} + +fn type_sum_variant<'a>( + module: &'a v3_compiler::parse_surface::SurfaceModule, + type_name: &str, + variant_name: &str, +) -> &'a SurfaceVariant { + module + .items + .iter() + .find_map(|item| match item { + SurfaceItem::TypeSum { name, variants, .. } if name == type_name => { + variants.iter().find(|variant| variant.name == variant_name) + } + _ => None, + }) + .unwrap_or_else(|| panic!("missing `{type_name}.{variant_name}` variant")) +} + +#[test] +fn emit_host_runner_rust_row_builds_runs_and_parses_stdout() { + let work_dir = emit_host_runner::default_work_dir(&format!( + "gunbc_v4_emit_host_harness_{}", + std::process::id() + )); + let inputs = emit_host_runner::EmitHostFixtureInputs { + claim_input_root: "w2_harness_claim_input".to_string(), + expected_eval_root: "w2_harness_expected_eval".to_string(), + }; + let receipt = + emit_host_runner::run_emit_host_rust(EMIT_HOST_FIXTURE_SOURCE, &inputs, &work_dir) + .expect("run_emit_host_rust"); + assert!( + receipt.exit.exit_holds(), + "expected successful host exit (Holds witness), got {:?}", + receipt.exit + ); + assert!( + emit_host_runner::host_logical_run_from_exit(&receipt.exit, receipt.stdout_bytes.clone()) + .is_some(), + "logical_run projection requires Holds exit" + ); + emit_host_runner::runtime_value_parse_rust(&receipt.stdout_bytes) + .expect("runtime_value_parse_rust on fixture stdout"); +} + +#[test] +fn v4_host_run_dag_tokenizes_and_parses_logical_run_carrier() { + let module = parse_module(HOST_RUN_DAG, HOST_RUN_PATH); + assert!( + surface_declares_type(&module, "HostRunStdout"), + "{HOST_RUN_PATH}: HostRunStdout carrier" + ); + assert!( + surface_declares_type(&module, "HostLogicalRun"), + "{HOST_RUN_PATH}: HostLogicalRun carrier" + ); + assert_eq!( + type_record_field_names(&module, "HostLogicalRun"), + vec!["stdout".to_string()], + "{HOST_RUN_PATH}: success-only logical run (no nested Witness)" + ); + assert!( + surface_declares_fn(&module, "host_logical_run_from_exit"), + "{HOST_RUN_PATH}: host_logical_run_from_exit" + ); +} + +#[test] +fn v4_falsification_dag_tokenizes_and_parses_execution_evidence() { + let module = parse_module(FALSIFICATION_DAG, FALSIFICATION_PATH); + assert!( + surface_declares_type(&module, "FalsificationReceipt"), + "{FALSIFICATION_PATH}: FalsificationReceipt" + ); + let receipt_fields = type_record_field_names(&module, "FalsificationReceipt"); + assert!( + receipt_fields.contains(&"subject".to_string()), + "{FALSIFICATION_PATH}: FalsificationReceipt.subject" + ); + for variant in ["Host", "Interpreter", "EvidenceNone"] { + let _ = type_sum_variant(&module, "ExecutionEvidence", variant); + } +} + +#[test] +fn v4_emit_host_dag_tokenizes_and_parses_fail_closed_surface() { + let module = parse_module(EMIT_HOST_DAG, EMIT_HOST_PATH); + for name in [ + "run_emit_host_rust", + "run_emit_host", + "host_exit_failure_outcome", + "run_test_claim_emit_vs_eval_for_claim", + "run_test_claim_emit_vs_eval", + ] { + assert!( + surface_declares_fn(&module, name), + "{EMIT_HOST_PATH}: missing fn {name}" + ); + } + assert!( + module.items.iter().any(|item| matches!( + item, + SurfaceItem::Data { name, .. } if name == "emit_host_transport_not_wired" + )), + "{EMIT_HOST_PATH}: transport_not_wired symbol" + ); +} + +#[test] +fn v4_nat_semiring_rung_gate_dag_tokenizes_and_parses_empty_roster_gates() { + let module = parse_module(NAT_SEMIRING_RUNG34_EVAL_DAG, NAT_SEMIRING_RUNG34_EVAL_PATH); + for name in [ + "nat_semiring_rung34_report_has_evidence", + "nat_semiring_rung3_gate", + "nat_semiring_rung4_gate", + "run_nat_semiring_rung34_eval", + ] { + assert!( + surface_declares_fn(&module, name), + "{NAT_SEMIRING_RUNG34_EVAL_PATH}: missing fn {name}" + ); + } + assert!( + module.items.iter().any(|item| matches!( + item, + SurfaceItem::Data { name, .. } if name == "nat_semiring_rung34_runtime_value_rows" + )), + "{NAT_SEMIRING_RUNG34_EVAL_PATH}: empty runtime roster" + ); +} diff --git a/src/v4/compiler/05_eval.dag b/src/v4/compiler/05_eval.dag index be6d5ec5d74..ccb61d04d4d 100644 --- a/src/v4/compiler/05_eval.dag +++ b/src/v4/compiler/05_eval.dag @@ -16,7 +16,7 @@ import v4.compiler.infer { import v4.std.algebra { fold_list } import v4.std.cardinality { RankingDimension, TerminationProof } import v4.std.collection { List, Map, list_nth } -import v4.std.verdict { Verdict, verdict_fold } +import v4.std.verdict { Verdict, verdict_fail, verdict_fold } import v4.std.runtime { EnvironmentBindingKey, Continue, @@ -451,7 +451,7 @@ type TestClaimCacheReceipt { // RULING-1: needs-more-work — executable corpus runner will replace Wave-0 run rows. type TestClaimRun { cache: TestClaimCacheReceipt - verdict: Verdict + verdict: Verdict, A> } @@ -477,7 +477,7 @@ fn test_claim_run_claim(run: TestClaimRun) -> TestClaim { } -fn test_claim_run_verdict(run: TestClaimRun) -> Verdict { +fn test_claim_run_verdict(run: TestClaimRun) -> Verdict, A> { run.verdict } @@ -1703,7 +1703,7 @@ fn run_test_claim_assert( claim: TestClaim, actual: Outcome, at: Node -) -> Verdict { +) -> Verdict, Node> { run_test_claim_assert_decided(claim: claim, actual: actual, at: at) } @@ -1712,13 +1712,13 @@ fn run_test_claim_assert_decided( claim: TestClaim, actual: Outcome, at: Node -) -> Verdict { +) -> Verdict, Node> { match claim { EqualsClaim { rhs: rhs, label: _, anchor: _, lhs: _, classification: _ } => if outcome_node_eq(a: actual, b: Accepted { value: rhs, diagnostics: None }) { Pass } else { - Fail { actual: actual } + verdict_fail(actual: actual) } CompilesClaim { expected_value: expected, label: _, anchor: _, input: _, classification: _ } => if outcome_node_eq( @@ -1727,7 +1727,7 @@ fn run_test_claim_assert_decided( ) { Pass } else { - Fail { actual: actual } + verdict_fail(actual: actual) } RoundTripClaim { input: input, label: _, anchor: _, classification: _ } => Deferred { @@ -1740,9 +1740,9 @@ fn run_test_claim_assert_decided( if outcome_node_eq(a: actual, b: Rejected { diagnostics: expected }) { Pass } else { - Fail { actual: actual } + verdict_fail(actual: actual) } - _ => Fail { actual: actual } + _ => verdict_fail(actual: actual) } } } @@ -1772,14 +1772,14 @@ fn run_test_claim_runtime_assert( interpretation: InterpretationAlgebra, actual: Outcome, at: Node -) -> Verdict { +) -> Verdict, RuntimeValue> { match claim { EqualsClaim { rhs: rhs, label: _, anchor: _, lhs: _, classification: _ } => { let expected = eval(tree: tree, interpretation: interpretation, inputs: Inputs { root: rhs }) if accepted_runtime_value_outcome_eq(a: actual, b: expected) { Pass } else { - Fail { actual: actual } + verdict_fail(actual: actual) } } CompilesClaim { expected_value: expected, label: _, anchor: _, input: _, classification: _ } => { @@ -1787,7 +1787,7 @@ fn run_test_claim_runtime_assert( if accepted_runtime_value_outcome_eq(a: actual, b: expected_outcome) { Pass } else { - Fail { actual: actual } + verdict_fail(actual: actual) } } RoundTripClaim { input: input, label: _, anchor: _, classification: _ } => @@ -1801,9 +1801,9 @@ fn run_test_claim_runtime_assert( if outcome_runtime_value_eq(a: actual, b: Rejected { diagnostics: expected }) { Pass } else { - Fail { actual: actual } + verdict_fail(actual: actual) } - _ => Fail { actual: actual } + _ => verdict_fail(actual: actual) } } } @@ -1811,14 +1811,17 @@ fn run_test_claim_runtime_assert( // Fail-closed: any Deferred or Fail in the list blocks Pass; [] is Fail (no fabricated Pass). // Empty-case policy: Fail with eval_empty_verdict_list diagnostic (context-specific at-node). -fn aggregate_verdicts(rs: List>, at: Node) -> Verdict { +fn aggregate_verdicts( + rs: List, T>>, + at: Node +) -> Verdict, T> { verdict_fold( rs: rs, - empty_verdict: Fail { + empty_verdict: verdict_fail( actual: outcome_rejected( d: eval_diagnostic(reason: eval_empty_verdict_list, node: at) ) - } + ) ) } diff --git a/src/v4/compiler/emit_host.dag b/src/v4/compiler/emit_host.dag new file mode 100644 index 00000000000..504d2de2763 --- /dev/null +++ b/src/v4/compiler/emit_host.dag @@ -0,0 +1,333 @@ +// src/v4/compiler/emit_host.dag +// Scope: T-38 / rung-4 host harness — emit + host execute + eval compare (compiler orchestration). +// Status: W2 substrate — executable boundary in tools/emit_host_runner; substrate transport stub +// (run_emit_host_rust → transport_not_wired) until W3/CI wiring; Python/Go rows Phase 3. +// Layer: compiler/ (imports emit/eval; std/ carries host_run + falsification receipts only). +// Authority: docs/planning/compiler-spine-runtime-rung34-min-runner-interface-2026-05-30.md §4.2 (A1–A4). + +module v4.compiler.emit_host + + +import v4.compiler.emit { emit } +import v4.compiler.eval { + Inputs, + TestClaimEvalSubject, + TestClaimRun, + accepted_runtime_value_outcome_eq, + eval, + eval_context_interpretation, + run_test_claim, + test_claim_cache_receipt_node +} +import v4.compiler.target_carriers { TargetSource } +import v4.std.collection { List, optional_present } +import v4.std.diagnostic { + Diagnostic, + Outcome, + Accepted, + Rejected, + None, + diagnostics_singleton, + port_locus, + node_locus, + outcome_rejected +} +import v4.std.host_run { + BuildLog, + ByteString, + EmitHostRunReceipt, + HostExit, + HostLogicalRun, + HostRunStdout, + ValueDiff, + host_logical_run_from_exit +} +import v4.std.test_claim_falsification { + ExecutionEvidence, + FalsificationReceipt, + Host +} +import v4.std.machine { Byte } +import v4.std.node { Atom, Node, Symbol, TypeNode } +import v4.std.runtime { RuntimeValue, RuntimePrimitive } +import v4.std.target_model { TargetModel } +import v4.std.text { String } +import v4.std.verdict { Verdict, Pass, Fail, Deferred, verdict_fail } +import v4.std.verification { + test_claim_input, + EqualsClaim, + CompilesClaim +} +import v4.std.witness { Holds, Violates } + + +data emit_host_transport_not_wired: Symbol = emit_host_transport_not_wired +data emit_host_transport_port: Symbol = emit_host_transport_port +data emit_host_parse_unsupported_target: Symbol = emit_host_parse_unsupported_target +data emit_host_run_unsupported_target: Symbol = emit_host_run_unsupported_target +data emit_host_emit_vs_eval_deferred: Symbol = emit_host_emit_vs_eval_deferred +data emit_host_exit_not_ok: Symbol = emit_host_exit_not_ok +data emit_host_rust_i64_symbol: Symbol = emit_host_rust_i64_symbol +// 🟡 coproduct dissolution — feature:emit-host-rust-authority-pin (joint spec §4.2 A3); +// bound task: W3 wires `run_emit_host_rust` + `nat_semiring_rung34_runtime_value_rows` +// (dissolves `emit_host_transport_not_wired`; retires hand-Rust harness per T-PB-B); +// dissolve-on: structural `runtime_row` on TargetModel replaces `authority_source_text` pin match. +data emit_host_rust_authority_pin: String = "fn add(x: i32, y: i32) -> i32 { x + y }" + +fn emit_host_transport_not_wired_diagnostic() -> Diagnostic { + Diagnostic { + reason: emit_host_transport_not_wired, + at: port_locus(port: emit_host_transport_port), + correction: None + } +} + +fn emit_host_parse_unsupported_target_diagnostic() -> Diagnostic { + Diagnostic { + reason: emit_host_parse_unsupported_target, + at: port_locus(port: emit_host_transport_port), + correction: None + } +} + +fn emit_host_run_unsupported_target_diagnostic() -> Diagnostic { + Diagnostic { + reason: emit_host_run_unsupported_target, + at: port_locus(port: emit_host_transport_port), + correction: None + } +} + +fn emit_host_emit_vs_eval_deferred_diagnostic(at: Node) -> Diagnostic { + Diagnostic { + reason: emit_host_emit_vs_eval_deferred, + at: node_locus(node: at), + correction: None + } +} + +fn emit_host_exit_not_ok_diagnostic() -> Diagnostic { + Diagnostic { + reason: emit_host_exit_not_ok, + at: port_locus(port: emit_host_transport_port), + correction: None + } +} + +fn host_exit_failure_outcome(exit: HostExit) -> Outcome { + match exit.outcome { + Accepted { value: Violates { diagnostic: d }, diagnostics: None } => + outcome_rejected(d: d) + Rejected { diagnostics: ds } => Rejected { diagnostics: ds } + _ => outcome_rejected(d: emit_host_exit_not_ok_diagnostic()) + } +} + +fn emit_host_rust_i64_node() -> Node { + Node { + kind: TypeNode { connective: Atom { identity: emit_host_rust_i64_symbol } }, + children: [] + } +} + +fn emit_host_receipt_from_source( + target: TargetModel, + source: TargetSource, + exit: HostExit, + stdout_bytes: ByteString, + stderr_bytes: ByteString, + build_log: BuildLog +) -> EmitHostRunReceipt { + EmitHostRunReceipt { + target: target, + source_text: source, + exit: exit, + logical_run: host_logical_run_from_exit(exit: exit, stdout_bytes: stdout_bytes), + stderr_bytes: stderr_bytes, + build_log: build_log + } +} + +fn runtime_value_parse_rust(bytes: ByteString) -> Outcome { + if count(bytes) == 5 { + Accepted { + value: RuntimePrimitive { + value: { + primitive_type: emit_host_rust_i64_node(), + bytes: bytes + } + }, + diagnostics: None + } + } else { + Rejected { + diagnostics: diagnostics_singleton(d: emit_host_parse_unsupported_target_diagnostic()) + } + } +} + +fn runtime_value_parse(target: TargetModel, stdout: HostRunStdout) -> Outcome { + if target.authority_source_text == emit_host_rust_authority_pin { + runtime_value_parse_rust(bytes: stdout.bytes) + } else { + Rejected { + diagnostics: diagnostics_singleton(d: emit_host_parse_unsupported_target_diagnostic()) + } + } +} + +// Dissolution trigger (W3): invoke tools/emit_host_runner with typed fixture_inputs (claim_input_root +// vs expected_eval_root pins); remove transport_not_wired on Rust row. +fn run_emit_host_rust( + target: TargetModel, + source: TargetSource, + fixture_inputs: Inputs +) -> Outcome { + // W3: fixture_inputs.root is claim_input_root; expected eval uses separate pin at call site. + Rejected { + diagnostics: diagnostics_singleton(d: emit_host_transport_not_wired_diagnostic()) + } +} + +fn run_emit_host( + target: TargetModel, + source: TargetSource, + fixture_inputs: Inputs +) -> Outcome { + if target.authority_source_text == emit_host_rust_authority_pin { + run_emit_host_rust(target: target, source: source, fixture_inputs: fixture_inputs) + } else { + Rejected { + diagnostics: diagnostics_singleton(d: emit_host_run_unsupported_target_diagnostic()) + } + } +} + +fn run_test_claim_emit_vs_eval_verdict( + subject: TestClaimEvalSubject, + target: TargetModel, + host_receipt: EmitHostRunReceipt, + host_actual: RuntimeValue, + expected: Outcome +) -> Verdict, RuntimeValue> { + match expected { + Accepted { value: exp_val, diagnostics: None } => + if accepted_runtime_value_outcome_eq( + a: Accepted { value: host_actual, diagnostics: None }, + b: expected + ) { + Pass + } else { + Fail { + actual: Accepted { value: host_actual, diagnostics: None }, + falsification: optional_present( + value: FalsificationReceipt { + subject: subject, + expected: exp_val, + actual: host_actual, + divergence: ValueDiff { expected: exp_val, actual: host_actual }, + evidence: Host { receipt: host_receipt } + } + ) + } + } + _ => + verdict_fail(actual: expected) + } +} + +// claim_input_root: lhs / CompilesClaim input (evaluator_input — FACTS FLOW FORWARD). +// expected_eval_root: rhs / expected_value (matches run_test_claim_runtime_assert). +fn run_test_claim_emit_vs_eval_for_claim( + subject: TestClaimEvalSubject, + target: TargetModel, + claim_input_root: Node, + expected_eval_root: Node +) -> Verdict, RuntimeValue> { + let at = test_claim_input(c: subject.claim) + let interpretation = eval_context_interpretation(context: subject.context) + let expected = eval( + tree: subject.tree, + interpretation: interpretation, + inputs: Inputs { root: expected_eval_root } + ) + match emit(tree: subject.tree, target: target) { + Accepted { value: source, diagnostics: _ } => + match run_emit_host( + target: target, + source: source, + fixture_inputs: Inputs { root: claim_input_root } + ) { + Accepted { value: host_receipt, diagnostics: _ } => + match host_receipt.logical_run { + Accepted { + value: HostLogicalRun { stdout: stdout }, + diagnostics: None + } => + match runtime_value_parse(target: target, stdout: stdout) { + Accepted { value: host_actual, diagnostics: _ } => + run_test_claim_emit_vs_eval_verdict( + subject: subject, + target: target, + host_receipt: host_receipt, + host_actual: host_actual, + expected: expected + ) + Rejected { diagnostics: ds } => + verdict_fail(actual: Rejected { diagnostics: ds }) + } + _ => + verdict_fail(actual: host_exit_failure_outcome(exit: host_receipt.exit)) + } + Rejected { diagnostics: ds } => + Deferred { + actual: Rejected { diagnostics: ds }, + diagnostic: emit_host_emit_vs_eval_deferred_diagnostic(at: at) + } + } + Rejected { diagnostics: ds } => + verdict_fail(actual: Rejected { diagnostics: ds }) + } +} + +fn run_test_claim_emit_vs_eval_run( + subject: TestClaimEvalSubject, + target: TargetModel, + verdict: Verdict, RuntimeValue> +) -> TestClaimRun { + TestClaimRun { + cache: test_claim_cache_receipt_node(subject: subject), + verdict: verdict + } +} + +fn run_test_claim_emit_vs_eval( + subject: TestClaimEvalSubject, + target: TargetModel +) -> TestClaimRun { + match subject.claim { + EqualsClaim { rhs: rhs, label: _, anchor: _, lhs: lhs, classification: _ } => + run_test_claim_emit_vs_eval_run( + subject: subject, + target: target, + verdict: run_test_claim_emit_vs_eval_for_claim( + subject: subject, + target: target, + claim_input_root: lhs, + expected_eval_root: rhs + ) + ) + CompilesClaim { expected_value: expected_value, label: _, anchor: _, input: input, classification: _ } => + run_test_claim_emit_vs_eval_run( + subject: subject, + target: target, + verdict: run_test_claim_emit_vs_eval_for_claim( + subject: subject, + target: target, + claim_input_root: input, + expected_eval_root: expected_value + ) + ) + _ => run_test_claim(subject: subject) + } +} diff --git a/src/v4/std/host_run.dag b/src/v4/std/host_run.dag new file mode 100644 index 00000000000..ae35232eaaa --- /dev/null +++ b/src/v4/std/host_run.dag @@ -0,0 +1,75 @@ +// src/v4/std/host_run.dag +// Scope: emit-host run receipts (W2 / joint runner spec §4.2). +// Status: Host arm of ExecutionEvidence; falsification lives in test_claim_falsification.dag. + +module v4.std.host_run + + +import v4.std.collection { List } +import v4.std.diagnostic { + Outcome, + Accepted, + Rejected, + None, + diagnostics_singleton +} +import v4.std.machine { Byte } +import v4.std.target_model { TargetModel } +import v4.std.text { String } +import v4.std.witness { Witness, Holds, Violates } + + +type ByteString = List + +type BuildLog { + lines: List +} + +type ExitOk { + code: Int +} + +// Named carrier (not a type alias): v2 compile rejects `type X = Outcome>`. +type HostExit { + outcome: Outcome> +} + +// Phase-typed logical-run stdout: only constructible when exit Holds (P2 host-process boundary). +type HostRunStdout { + bytes: ByteString +} + +// Success-only logical-run carrier: `Outcome` carries reject/diagnostic arms; +// the accepted payload is only `HostRunStdout` (no nested `Witness` — illegal Violates unrepresentable). +type HostLogicalRun { + stdout: HostRunStdout +} + +type EmitHostRunReceipt { + target: TargetModel + source_text: String + exit: HostExit + logical_run: Outcome + stderr_bytes: ByteString + build_log: BuildLog +} + +fn host_logical_run_from_exit(exit: HostExit, stdout_bytes: ByteString) -> Outcome { + match exit.outcome { + Accepted { value: Holds { value: _ }, diagnostics: None } => + Accepted { + value: HostLogicalRun { + stdout: HostRunStdout { bytes: stdout_bytes } + }, + diagnostics: None + } + Accepted { value: Violates { diagnostic: d }, diagnostics: None } => + Rejected { diagnostics: diagnostics_singleton(d: d) } + Rejected { diagnostics: ds } => Rejected { diagnostics: ds } + } +} + +type ValueDiff { + expected: A + actual: A +} diff --git a/src/v4/std/test_claim_falsification.dag b/src/v4/std/test_claim_falsification.dag new file mode 100644 index 00000000000..f07b52b7010 --- /dev/null +++ b/src/v4/std/test_claim_falsification.dag @@ -0,0 +1,36 @@ +// src/v4/std/test_claim_falsification.dag +// Scope: falsification receipts + execution evidence (PR #3961 verdict-surface-contract). +// Authority: docs/planning/verdict-surface-contract-2026-05-30.md +// Layering: no v4.compiler imports — Subj is bound to TestClaimEvalSubject at compiler call sites. + +module v4.std.test_claim_falsification + + +import v4.std.collection { List } +import v4.std.diagnostic { Diagnostic } +import v4.std.host_run { EmitHostRunReceipt, ValueDiff } +import v4.std.node { Symbol } + + +type InterpreterTrace { + pinned_eval_call: Symbol + diagnostics: List +} + +// 🟡 coproduct dissolution — feature:verdict-surface-execution-evidence (#3961); +// bound task: W3 wires `run_emit_host_rust` (dissolves `emit_host_transport_not_wired`) and +// interpreter trace population for emit-vs-eval rows; `EvidenceNone` is the fail-closed empty arm. +type ExecutionEvidence + = Host { receipt: EmitHostRunReceipt } + | Interpreter { trace: InterpreterTrace } + | EvidenceNone + +// Subj: eval subject carrier (compiler binds TestClaimEvalSubject at TestClaimRun boundary). +// Keeps std/ free of v4.compiler imports (P2 layering; breaks verdict→falsification→eval cycle). +type FalsificationReceipt { + subject: Subj + expected: A + actual: A + divergence: ValueDiff + evidence: ExecutionEvidence +} diff --git a/src/v4/std/verdict.dag b/src/v4/std/verdict.dag index 2de48887242..e1cdf5100c5 100644 --- a/src/v4/std/verdict.dag +++ b/src/v4/std/verdict.dag @@ -1,64 +1,80 @@ // src/v4/std/verdict.dag // Scope: test-run verdict algebra. // Anchor: docs/design-v4-compiler-homomorphism.md P3 commitment 6 + T-22 eval runner. -// Status: AI-10 substrate declaration — moves Pass/Fail/Deferred algebra out of 05_eval.dag. +// Status: AI-10 substrate; Verdict + Fail.falsification per verdict-surface-contract (#3961). +// Migration (#3961 §3.1 / Runtime/TestClaim mgr): #3958 merges `Fail { actual, falsification }`; +// bounded follow-up collapses to `Fail { receipt: FalsificationReceipt }` (no dual-actual). // -// ALGEBRA: (Verdict, verdict_combine, Pass) forms a Monoid where: +// ALGEBRA: (Verdict, verdict_combine, Pass) forms a Monoid where: // Pass is the identity (bottom element of the join order). -// Fail absorbs (join: worst outcome wins). +// Fail absorbs (join: worst outcome wins); falsification on Fail is preserved from the left. // Deferred is sticky: once Deferred, only Fail can promote. // Ordering: Pass < Deferred < Fail (verdict_combine is the join operation). // BoundedLattice top (Fail) is not representable as a concrete data constant because // Fail carries actual: Outcome — no payload-free top exists for general T. // The ordering is structurally sound but the lattice inhabitant requires a concrete T. -// verdict_monoid declares the Monoid> inhabitant as a function (not data) -// because Verdict is polymorphic and Monoid fields must resolve at T. +// verdict_monoid declares the Monoid> inhabitant as a function (not data) +// because Verdict is polymorphic and Monoid fields must resolve at S and T. // verdict_fold fails-closed on empty list: caller supplies the empty-case verdict. - module v4.std.verdict import v4.std.algebra { Magma, Monoid, Semigroup, is_empty } -import v4.std.collection { List } +import v4.std.collection { List, Optional, Absent } import v4.std.diagnostic { Diagnostic, Outcome } import v4.std.nat { Nat, Succ, Zero, nat_add } +import v4.std.test_claim_falsification { FalsificationReceipt } + // 🟢 coproduct dissolution — CP-AI10-VERDICT-TERMINAL. +// Subj: eval-subject carrier for Fail.falsification (compiler binds TestClaimEvalSubject). // Pass: all expected behaviors confirmed. -// Fail: a claim did not hold; actual outcome is attached for diagnostics. +// Fail: a claim did not hold; actual outcome plus optional structured falsification receipt. +// W2 single-absence rule: `falsification: Absent` means no receipt (eval-path / verdict_fail). +// `Present { receipt }` is only for callers that populate FalsificationReceipt (e.g. emit-vs-eval +// Host evidence). Do not encode “no receipt” as `Present { …, evidence: EvidenceNone }` on this +// branch — EvidenceNone is a receipt-internal arm for W3 structural rows (#3961 follow-up). // Deferred: the evaluator could not decide (e.g. eval not yet realized); // a diagnostic explains why, and the actual outcome is preserved. -type Verdict +type Verdict = Pass - | Fail { actual: Outcome } + | Fail { actual: Outcome, falsification: Optional> } | Deferred { actual: Outcome, diagnostic: Diagnostic } + +// Eval-path Fail without structured receipt — sole W2 encoding of missing falsification. +fn verdict_fail(actual: Outcome) -> Verdict { + Fail { actual: actual, falsification: Absent } +} + // Binary combining operation for verdicts (join of the Pass < Deferred < Fail order). // Laws: // verdict_combine(Pass, v) = v (Pass is identity) -// verdict_combine(Fail{a}, _) = Fail{a} (Fail absorbs) -// verdict_combine(Deferred{d,diag}, Fail{b}) = Fail{b} (Fail promotes Deferred) +// verdict_combine(Fail{a,ff}, _) = Fail{a,ff} (Fail absorbs; left falsification kept) +// verdict_combine(Deferred{d,diag}, Fail{b,bf}) = Fail{b,bf} (Fail promotes Deferred) // verdict_combine(Deferred{d,diag}, _) = Deferred{d,diag} (Deferred sticky otherwise) -fn verdict_combine(a: Verdict, b: Verdict) -> Verdict { +fn verdict_combine(a: Verdict, b: Verdict) -> Verdict { match a { Pass => b - Fail { actual: fa } => Fail { actual: fa } + Fail { actual: fa, falsification: ff } => Fail { actual: fa, falsification: ff } Deferred { actual: da, diagnostic: diag } => match b { - Fail { actual: fb } => Fail { actual: fb } + Fail { actual: fb, falsification: bf } => Fail { actual: fb, falsification: bf } Deferred { actual: _, diagnostic: _ } => Deferred { actual: da, diagnostic: diag } Pass => Deferred { actual: da, diagnostic: diag } } } } -// Monoid> inhabitant: op = verdict_combine, identity = Pass. -// Declared as a fn (not data) because Verdict is polymorphic. + +// Monoid> inhabitant: op = verdict_combine, identity = Pass. +// Declared as a fn (not data) because Verdict is polymorphic. // Mirrors the nat_additive_commutative_monoid pattern in nat.dag:57. -fn verdict_monoid() -> Monoid> { +fn verdict_monoid() -> Monoid> { Monoid { semigroup: Semigroup { magma: Magma { op: verdict_combine } }, identity: Pass } } -// Tally of Verdict populations by variant. Carries genuinely distinct + +// Tally of Verdict populations by variant. Carries genuinely distinct // per-variant work (different field increments in `verdict_tally_add`), // so it is the canonical surface for "how many Pass / Fail / Deferred" // over a list — NOT per-variant `is_*` Bool predicates (which would be @@ -71,29 +87,36 @@ type VerdictTally { fail: Nat deferred: Nat } + data verdict_tally_empty: VerdictTally = VerdictTally { pass: Zero, fail: Zero, deferred: Zero } -fn verdict_tally_add(t: VerdictTally, v: Verdict) -> VerdictTally { + +fn verdict_tally_add(t: VerdictTally, v: Verdict) -> VerdictTally { match v { Pass => VerdictTally { pass: Succ { prev: t.pass }, fail: t.fail, deferred: t.deferred } - Fail { actual: _ } => VerdictTally { pass: t.pass, fail: Succ { prev: t.fail }, deferred: t.deferred } - Deferred { actual: _, diagnostic: _ } => VerdictTally { pass: t.pass, fail: t.fail, deferred: Succ { prev: t.deferred } } + Fail { actual: _, falsification: _ } => + VerdictTally { pass: t.pass, fail: Succ { prev: t.fail }, deferred: t.deferred } + Deferred { actual: _, diagnostic: _ } => + VerdictTally { pass: t.pass, fail: t.fail, deferred: Succ { prev: t.deferred } } } } -fn verdict_tally(vs: List>) -> VerdictTally { + +fn verdict_tally(vs: List>) -> VerdictTally { fold(vs, init: verdict_tally_empty, f: fn(acc, v) { verdict_tally_add(t: acc, v: v) }) } + fn verdict_tally_total(t: VerdictTally) -> Nat { nat_add(a: nat_add(a: t.pass, b: t.fail), b: t.deferred) } + // Fold a list of verdicts with the monoid identity (Pass) and verdict_combine. // Fail-closed on empty list: the caller supplies the empty_verdict to enforce // the desired policy (typically Fail with a context-specific diagnostic). -fn verdict_fold( - rs: List>, - empty_verdict: Verdict -) -> Verdict { +fn verdict_fold( + rs: List>, + empty_verdict: Verdict +) -> Verdict { match is_empty(xs: rs) { true => empty_verdict false => fold(rs, init: Pass, f: fn(acc, v) { diff --git a/src/v4/test/claim/generated/language_behavior_equivalence.dag b/src/v4/test/claim/generated/language_behavior_equivalence.dag index 10cf5f44e99..a8834443eaa 100644 --- a/src/v4/test/claim/generated/language_behavior_equivalence.dag +++ b/src/v4/test/claim/generated/language_behavior_equivalence.dag @@ -16,6 +16,7 @@ import v4.compiler.eval { test_claim_cache_receipt, test_claim_typed_input_node } +import v4.std.collection { Absent } import v4.std.verdict { Verdict, Deferred, Fail, Pass } import v4.compiler.infer { InferredFacts, @@ -273,12 +274,12 @@ fn lbe_run_test_claim_assert_from_emit( anchor: ManualAnchorKey, label: String, mock: LanguageBehaviorIoMock -) -> Verdict { +) -> Verdict, Node> { match lbe_claim_from_testgen_emit(anchor: anchor, label: label, mock: mock) { Accepted { value: claim, diagnostics: None } => lbe_run_test_claim_assert_from_mock(claim: claim, mock: mock) Rejected { diagnostics: d } => - Fail { actual: Rejected { diagnostics: d } } + Fail { actual: Rejected { diagnostics: d }, falsification: Absent } } } @@ -294,7 +295,7 @@ fn lbe_test_claim_run_from_emit( Rejected { diagnostics: d } => TestClaimRun { cache: lbe_rejected_emit_cache_receipt(label: label, mock: mock, expected: d), - verdict: Fail { actual: Rejected { diagnostics: d } } + verdict: Fail { actual: Rejected { diagnostics: d }, falsification: Absent } } } } @@ -311,7 +312,7 @@ fn lbe_mock_actual(mock: LanguageBehaviorIoMock) -> Outcome { fn lbe_run_test_claim_assert_from_mock( claim: TestClaim, mock: LanguageBehaviorIoMock -) -> Verdict { +) -> Verdict, Node> { run_test_claim_assert( claim: claim, actual: lbe_mock_actual(mock: mock), @@ -326,7 +327,7 @@ data witness_lbe_conj_snapshot_pass: Bool = match lbe_run_test_claim_assert_from mock: lbe_io_mock_conj_dag_surface() ) { Pass => true - Fail { actual: _ } => false + Fail { actual: _, falsification: _ } => false Deferred { actual: _, diagnostic: _ } => false } @@ -337,7 +338,7 @@ data witness_lbe_disj_snapshot_pass: Bool = match lbe_run_test_claim_assert_from mock: lbe_io_mock_disj_dag_surface() ) { Pass => true - Fail { actual: _ } => false + Fail { actual: _, falsification: _ } => false Deferred { actual: _, diagnostic: _ } => false } @@ -348,7 +349,7 @@ data witness_lbe_transform_snapshot_pass: Bool = match lbe_run_test_claim_assert mock: lbe_io_mock_transform_dag_surface() ) { Pass => true - Fail { actual: _ } => false + Fail { actual: _, falsification: _ } => false Deferred { actual: _, diagnostic: _ } => false } diff --git a/src/v4/test/claim/manual/diagnostic_assert_eval.dag b/src/v4/test/claim/manual/diagnostic_assert_eval.dag index 91c48f1dd6e..4f898c10c9d 100644 --- a/src/v4/test/claim/manual/diagnostic_assert_eval.dag +++ b/src/v4/test/claim/manual/diagnostic_assert_eval.dag @@ -7,11 +7,13 @@ module v4.test.claim.manual.diagnostic_assert_eval import v4.compiler.eval { + TestClaimEvalSubject, aggregate_verdicts, eval_diagnostic, run_test_claim_assert } import v4.std.verdict { Verdict, Pass, Fail, Deferred } +import v4.std.node { Node } import v4.std.diagnostic { AmbiguousIntent, Diagnostic, @@ -119,7 +121,7 @@ data witness_diagnostic_assert_match_pass: Bool = match run_test_claim_assert( at: diag_assert_node() ) { Pass => true - Fail { actual: _ } => false + Fail { actual: _, falsification: _ } => false Deferred { actual: _, diagnostic: _ } => false } @@ -130,12 +132,12 @@ data witness_diagnostic_assert_mismatch_fail: Bool = match run_test_claim_assert at: diag_assert_node() ) { Pass => false - Fail { actual: _ } => true + Fail { actual: _, falsification: _ } => true Deferred { actual: _, diagnostic: _ } => false } -fn empty_node_verdicts() -> List> { +fn empty_node_verdicts() -> List, Node>> { [] } @@ -146,19 +148,19 @@ data witness_diagnostic_assert_accepted_fail: Bool = match run_test_claim_assert at: diag_assert_node() ) { Pass => false - Fail { actual: _ } => true + Fail { actual: _, falsification: _ } => true Deferred { actual: _, diagnostic: _ } => false } data witness_aggregate_empty_fail: Bool = match aggregate_verdicts(rs: empty_node_verdicts(), at: diag_assert_node()) { Pass => false - Fail { actual: _ } => true + Fail { actual: _, falsification: _ } => true Deferred { actual: _, diagnostic: _ } => false } -fn deferred_verdict() -> Verdict { +fn deferred_verdict() -> Verdict, Node> { Deferred { actual: rejected_same, diagnostic: eval_diagnostic(reason: diag_assert_reason, node: diag_assert_node()) @@ -168,6 +170,6 @@ fn deferred_verdict() -> Verdict { data witness_aggregate_deferred_blocks_pass: Bool = match aggregate_verdicts(rs: [Pass, deferred_verdict()], at: diag_assert_node()) { Pass => false - Fail { actual: _ } => false + Fail { actual: _, falsification: _ } => false Deferred { actual: _, diagnostic: _ } => true } diff --git a/src/v4/test/claim/manual/dissolution_subsumption_reverification.dag b/src/v4/test/claim/manual/dissolution_subsumption_reverification.dag index 2c8c4908d40..3dffd1ed3ae 100644 --- a/src/v4/test/claim/manual/dissolution_subsumption_reverification.dag +++ b/src/v4/test/claim/manual/dissolution_subsumption_reverification.dag @@ -243,7 +243,7 @@ fn completed_lens_ci_execution() -> MechanicalReverificationExecution { fn mechanical_reverification_execution_for_run(run: TestClaimRun) -> MechanicalReverificationExecution { match run.verdict { Pass => completed_lens_ci_execution() - Fail { actual: _ } => Failed + Fail { actual: _, falsification: _ } => Failed Deferred { actual: _, diagnostic: _ } => ReportUnavailable } } diff --git a/src/v4/test/claim/manual/infer_ground_add_mvp.dag b/src/v4/test/claim/manual/infer_ground_add_mvp.dag index 9d6723be2ba..598d0215259 100644 --- a/src/v4/test/claim/manual/infer_ground_add_mvp.dag +++ b/src/v4/test/claim/manual/infer_ground_add_mvp.dag @@ -15,7 +15,7 @@ import v4.compiler.eval { test_claim_cache_receipt, test_claim_typed_input_node } -import v4.std.verdict { Verdict, Fail, Pass } +import v4.std.verdict { Verdict, Pass, verdict_fail } import v4.compiler.infer { infer, infer_descent_not_derived_diagnostic, @@ -323,7 +323,7 @@ fn infer_add_mvp_receipt_run( ) { Pass } else { - Fail { actual: actual } + verdict_fail(actual: actual) } Rejected { diagnostics: _ } => if outcome_node_eq( @@ -332,10 +332,10 @@ fn infer_add_mvp_receipt_run( ) { Pass } else { - Fail { actual: actual } + verdict_fail(actual: actual) } } - Rejected { diagnostics: _ } => Fail { actual: actual } + Rejected { diagnostics: _ } => verdict_fail(actual: actual) } } } @@ -352,9 +352,9 @@ fn infer_descent_witness_receipt_run( if outcome_node_eq(a: actual, b: expected) { Pass } else { - Fail { actual: actual } + verdict_fail(actual: actual) } - Rejected { diagnostics: _ } => Fail { actual: actual } + Rejected { diagnostics: _ } => verdict_fail(actual: actual) } } } diff --git a/src/v4/test/claim/workflow/nat_semiring_rung34_eval.dag b/src/v4/test/claim/workflow/nat_semiring_rung34_eval.dag new file mode 100644 index 00000000000..7e5eaaa7e98 --- /dev/null +++ b/src/v4/test/claim/workflow/nat_semiring_rung34_eval.dag @@ -0,0 +1,67 @@ +// src/v4/test/claim/workflow/nat_semiring_rung34_eval.dag +// Scope: W2 rung 3–4 corpus eval + rung-split gates (§4.4) for phase1/nat_semiring. +// Status: roster wedge empty until W3 lands rung_3_4 claims; compiler/emit_host stub until W3 wiring. + +module v4.test.claim.workflow.nat_semiring_rung34_eval + + +import v4.compiler.eval { TestClaimRun } +import v4.std.algebra { is_empty } +import v4.std.collection { List } +import v4.std.logic { Bool } +import v4.std.nat { Nat, Zero } +import v4.std.node { Node } +import v4.std.runtime { RuntimeValue } +import v4.test.claim.workflow.testclaim_corpus_runner { + CorpusEvalReport, + corpus_entries_from_node_runtime_value_runs, + corpus_report_tally +} + + +// W3 populates via imports from src/v4/test/claim/nat_semiring/rung_3_4_*.dag. +data nat_semiring_rung34_runtime_value_rows: List> = [] + + +fn run_nat_semiring_rung34_eval() -> CorpusEvalReport { + CorpusEvalReport { + entries: corpus_entries_from_node_runtime_value_runs( + runs: nat_semiring_rung34_runtime_value_rows + ) + } +} + + +// W2: empty roster must not read as closed (fail-closed until W3 populates rows). +fn nat_semiring_rung34_report_has_evidence(report: CorpusEvalReport) -> Bool { + match is_empty(xs: report.entries) { + true => false + false => true + } +} + +// W3 backlog (§4.4): bodies are intentionally identical while the roster is empty. +// Once nat_semiring_rung34_runtime_value_rows is populated, each gate must discriminate +// disjoint claim subsets — filter by claim-kind here (RoundTrip vs emit-vs-eval) or accept +// pre-filtered CorpusEvalReport slices from W3. +fn nat_semiring_rung3_gate(report: CorpusEvalReport) -> Bool { + if nat_semiring_rung34_report_has_evidence(report: report) { + let tally = corpus_report_tally(r: report) + tally.fail == Zero && tally.deferred == Zero + } else { + false + } +} + +fn nat_semiring_rung4_gate(report: CorpusEvalReport) -> Bool { + if nat_semiring_rung34_report_has_evidence(report: report) { + let tally = corpus_report_tally(r: report) + tally.fail == Zero && tally.deferred == Zero + } else { + false + } +} + +fn nat_semiring_rungs_34_closed(report: CorpusEvalReport) -> Bool { + nat_semiring_rung3_gate(report: report) && nat_semiring_rung4_gate(report: report) +} diff --git a/src/v4/test/claim/workflow/pipeline_rejections.dag b/src/v4/test/claim/workflow/pipeline_rejections.dag index 7bf4f1b5bda..fc626f6d974 100644 --- a/src/v4/test/claim/workflow/pipeline_rejections.dag +++ b/src/v4/test/claim/workflow/pipeline_rejections.dag @@ -20,7 +20,7 @@ import v4.workflow.ci { workflow_pipeline_plan_bound_pass_receipt, WorkflowPipelinePlanBoundPassReceipt } -import v4.std.verdict { Verdict, Fail, Pass } +import v4.std.verdict { Verdict, Pass, verdict_fail } import v4.compiler.infer { InferredFacts, InferredTree @@ -110,7 +110,7 @@ fn run_pipeline_assert( claim: TestClaim, actual: Outcome, input: Node -) -> Verdict { +) -> Verdict, CiPipeline> { match test_claim_expected_outcome(c: claim) { Accepted { value: expected, diagnostics: _ } => match claim { @@ -118,15 +118,15 @@ fn run_pipeline_assert( match actual { Rejected { diagnostics: r } => { let comparable = Rejected { diagnostics: r } - if outcome_node_eq(a: comparable, b: expected) { Pass } else { Fail { actual: actual } } + if outcome_node_eq(a: comparable, b: expected) { Pass } else { verdict_fail(actual: actual) } } - _ => Fail { actual: actual } + _ => verdict_fail(actual: actual) } _ => - Fail { actual: actual } + verdict_fail(actual: actual) } Rejected { diagnostics: _ } => - Fail { actual: actual } + verdict_fail(actual: actual) } } @@ -346,7 +346,7 @@ data subject_dangling_bootstrap_output: TestClaimEvalSubject = pipel data run_dangling_bootstrap_output: TestClaimRun = workflow_pipeline_run(subject: subject_dangling_bootstrap_output) -data gate_dangling_bootstrap_output: Verdict> = workflow_pipeline_pass_gate( +data gate_dangling_bootstrap_output: Verdict, TestClaimRun> = workflow_pipeline_pass_gate( run: run_dangling_bootstrap_output, at: test_claim_input(c: subject_dangling_bootstrap_output.claim) ) @@ -380,7 +380,7 @@ data subject_duplicate_job_id: TestClaimEvalSubject = pipeline_rejec data run_duplicate_job_id: TestClaimRun = workflow_pipeline_run(subject: subject_duplicate_job_id) -data gate_duplicate_job_id: Verdict> = workflow_pipeline_pass_gate( +data gate_duplicate_job_id: Verdict, TestClaimRun> = workflow_pipeline_pass_gate( run: run_duplicate_job_id, at: test_claim_input(c: subject_duplicate_job_id.claim) ) @@ -414,7 +414,7 @@ data subject_duplicate_gate_id: TestClaimEvalSubject = pipeline_reje data run_duplicate_gate_id: TestClaimRun = workflow_pipeline_run(subject: subject_duplicate_gate_id) -data gate_duplicate_gate_id: Verdict> = workflow_pipeline_pass_gate( +data gate_duplicate_gate_id: Verdict, TestClaimRun> = workflow_pipeline_pass_gate( run: run_duplicate_gate_id, at: test_claim_input(c: subject_duplicate_gate_id.claim) ) @@ -447,7 +447,7 @@ data subject_dangling_need: TestClaimEvalSubject = pipeline_rejectio data run_dangling_need: TestClaimRun = workflow_pipeline_run(subject: subject_dangling_need) -data gate_dangling_need: Verdict> = workflow_pipeline_pass_gate( +data gate_dangling_need: Verdict, TestClaimRun> = workflow_pipeline_pass_gate( run: run_dangling_need, at: test_claim_input(c: subject_dangling_need.claim) ) @@ -480,7 +480,7 @@ data subject_dangling_gate_job: TestClaimEvalSubject = pipeline_reje data run_dangling_gate_job: TestClaimRun = workflow_pipeline_run(subject: subject_dangling_gate_job) -data gate_dangling_gate_job: Verdict> = workflow_pipeline_pass_gate( +data gate_dangling_gate_job: Verdict, TestClaimRun> = workflow_pipeline_pass_gate( run: run_dangling_gate_job, at: test_claim_input(c: subject_dangling_gate_job.claim) ) @@ -515,7 +515,7 @@ data subject_dependency_cycle: TestClaimEvalSubject = pipeline_rejec data run_dependency_cycle: TestClaimRun = workflow_pipeline_run(subject: subject_dependency_cycle) -data gate_dependency_cycle: Verdict> = workflow_pipeline_pass_gate( +data gate_dependency_cycle: Verdict, TestClaimRun> = workflow_pipeline_pass_gate( run: run_dependency_cycle, at: test_claim_input(c: subject_dependency_cycle.claim) ) diff --git a/src/v4/workflow/ci.dag b/src/v4/workflow/ci.dag index e86cbc49cc4..8342094e6c5 100644 --- a/src/v4/workflow/ci.dag +++ b/src/v4/workflow/ci.dag @@ -66,7 +66,7 @@ import v4.std.node { import v4.std.integer { int_add, int_negate } import v4.std.algebra { Empty, fold_list, list_snoc_item } import v4.std.verdict { Verdict, Deferred, Fail, Pass } -import v4.std.collection { List } +import v4.std.collection { Absent, List } import v4.std.text { Char, String } import v4.std.diagnostic { Correction, @@ -1118,7 +1118,7 @@ fn workflow_pipeline_receipt_failure_diagnostics( run: TestClaimRun ) -> List { match run.verdict { - Fail { actual: Rejected { diagnostics: ds } } => ds + Fail { actual: Rejected { diagnostics: ds }, falsification: _ } => ds // Unreachable from `workflow_pipeline_plan_bound_pass_receipt` Fail arm today; honest tag if call sites change. _ => diagnostics_singleton(d: ci_diagnostic(reason: ci_workflow_receipt_unexpected_verdict)) @@ -1130,12 +1130,13 @@ fn workflow_pipeline_receipt_failure_diagnostics( fn workflow_pipeline_pass_gate( run: TestClaimRun, at: Node -) -> Verdict> { +) -> Verdict, TestClaimRun> { aggregate_verdicts( rs: [ match run.verdict { Pass => Pass - Fail { actual: _ } => Fail { actual: Accepted { value: run, diagnostics: None } } + Fail { actual: _, falsification: _ } => + Fail { actual: Accepted { value: run, diagnostics: None }, falsification: Absent } Deferred { actual: _, diagnostic: d } => Deferred { actual: Accepted { value: run, diagnostics: None }, diagnostic: d } } @@ -1162,7 +1163,7 @@ fn workflow_pipeline_plan_bound_pass_receipt( } Rejected { diagnostics: ds } => Rejected { diagnostics: ds } } - Fail { actual: _ } => + Fail { actual: _, falsification: _ } => Rejected { diagnostics: workflow_pipeline_receipt_failure_diagnostics(run: run) } Deferred { actual: _, diagnostic: d } => Rejected { diagnostics: diagnostics_singleton(d: d) } diff --git a/tools/emit_host_runner/Cargo.toml b/tools/emit_host_runner/Cargo.toml new file mode 100644 index 00000000000..c190f3919f7 --- /dev/null +++ b/tools/emit_host_runner/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "emit_host_runner" +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false + +[lib] +path = "src/lib.rs" diff --git a/tools/emit_host_runner/src/lib.rs b/tools/emit_host_runner/src/lib.rs new file mode 100644 index 00000000000..d21e5d8468e --- /dev/null +++ b/tools/emit_host_runner/src/lib.rs @@ -0,0 +1,542 @@ +//! v4 `compiler/emit_host.dag` Rust host row — compile + execute emitted artifacts. +//! +//! **Modeled authority:** `run_emit_host_rust`, `runtime_value_parse_rust` in +//! `src/v4/compiler/emit_host.dag`. Substrate `.dag` dispatch is fail-closed +//! (`transport_not_wired`) until W3 wires this crate into `run_emit_host_rust`; this crate is +//! the executable host-process boundary exercised by `v4_emit_host_harness_test.rs`. +//! +//! **Host boundary (INVARIANTS §P2):** outcomes are typed carriers — setup failure is +//! `HostExitOutcome::Rejected(HostSetupFailure)`, logical child outcome is +//! `HostExitOutcome::Accepted(ExitWitness::Holds|Violates)`. No free-form `String` authority. +//! **W3 dissolution:** map `HostExit` / `HostLogicalRun` into `v4.std.host_run` carriers when +//! `run_emit_host_rust` transport lands (dissolves `emit_host_transport_not_wired`). +//! +//! Child processes use wall-clock timeouts and per-stream byte caps so a buggy emitted program +//! cannot hang `cargo test` or exhaust memory. + +use std::fmt; +use std::fs; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Command, ExitStatus, Stdio}; +use std::sync::mpsc; +use std::thread; +use std::time::{Duration, Instant}; + +/// Wall-clock bound for `cargo build` of the ephemeral fixture crate. +pub const HOST_BUILD_TIMEOUT: Duration = Duration::from_secs(300); +/// Wall-clock bound for running the compiled fixture binary. +pub const HOST_RUN_TIMEOUT: Duration = Duration::from_secs(30); +/// Per-stream capture cap (stdout and stderr each). +pub const HOST_STREAM_BYTE_CAP: usize = 1 << 20; + +/// Typed exit: success only when the child process exited 0. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExitOk { + pub code: i32, +} + +/// Which host-process phase produced an outcome (setup vs logical child). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HostPhase { + Build, + FixtureRun, +} + +/// Child stream identifier for setup failures. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HostStream { + Stdout, + Stderr, +} + +/// Harness / transport setup failure — distinct from logical child exit (INVARIANTS §P2(c)). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HostSetupFailure { + SpawnFailed { + phase: HostPhase, + source: String, + }, + StdoutPipeMissing { + phase: HostPhase, + }, + StderrPipeMissing { + phase: HostPhase, + }, + TryWaitFailed { + phase: HostPhase, + source: String, + }, + StreamReadFailed { + phase: HostPhase, + stream: HostStream, + source: String, + }, + WorkDirCreateFailed { + source: String, + }, + ManifestWriteFailed { + source: String, + }, + SourceWriteFailed { + source: String, + }, + EmptyClaimInputRoot, + EmptyExpectedEvalRoot, +} + +/// Logical child ran but did not satisfy the exit witness (timeout, nonzero, missing status). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HostLogicalFailure { + TimedOut { phase: HostPhase }, + NoExitStatus { phase: HostPhase }, + ExitedNonzero { phase: HostPhase, code: Option }, +} + +/// Mirrors `.dag` `Witness` at the Rust transport row. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ExitWitness { + Holds(ExitOk), + Violates(HostLogicalFailure), +} + +/// Mirrors `.dag` `Outcome>` at the Rust transport row. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HostExitOutcome { + Accepted(ExitWitness), + Rejected(HostSetupFailure), +} + +/// Mirrors `v4.std.host_run.HostExit` — typed setup vs logical exit separation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HostExit { + pub outcome: HostExitOutcome, +} + +impl HostExit { + pub fn holds(code: i32) -> Self { + Self { + outcome: HostExitOutcome::Accepted(ExitWitness::Holds(ExitOk { code })), + } + } + + pub fn logical_violation(failure: HostLogicalFailure) -> Self { + Self { + outcome: HostExitOutcome::Accepted(ExitWitness::Violates(failure)), + } + } + + pub fn setup_rejected(failure: HostSetupFailure) -> Self { + Self { + outcome: HostExitOutcome::Rejected(failure), + } + } + + pub fn exit_holds(&self) -> bool { + matches!( + self.outcome, + HostExitOutcome::Accepted(ExitWitness::Holds(_)) + ) + } +} + +/// Phase-typed logical-run stdout — only `Some` when exit witness Holds (P2 boundary). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HostLogicalRun { + pub stdout_bytes: Vec, +} + +/// Project exit + captured stdout into logical-run carrier (mirrors `host_logical_run_from_exit`). +pub fn host_logical_run_from_exit( + exit: &HostExit, + stdout_bytes: Vec, +) -> Option { + match &exit.outcome { + HostExitOutcome::Accepted(ExitWitness::Holds(_)) => Some(HostLogicalRun { stdout_bytes }), + HostExitOutcome::Accepted(ExitWitness::Violates(_)) | HostExitOutcome::Rejected(_) => None, + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BuildLog { + pub lines: Vec, +} + +/// Host fixture input pins — mirrors `.dag` split of `claim_input_root` (evaluator input / +/// `Inputs.root` for `run_emit_host`) vs `expected_eval_root` (rhs / `expected_value` for eval). +/// W2 MVP fixture ignores pin semantics; W3 wires Node→host ABI and rejects mismatched pins. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EmitHostFixtureInputs { + pub claim_input_root: String, + pub expected_eval_root: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EmitHostRunReceipt { + pub source_text: String, + pub exit: HostExit, + pub stdout_bytes: Vec, + pub stderr_bytes: Vec, + pub build_log: BuildLog, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RuntimeValueParseFailure { + pub expected_len: usize, + pub actual_len: usize, +} + +impl fmt::Display for RuntimeValueParseFailure { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "runtime_value_parse_rust: expected {} stdout bytes, got {}", + self.expected_len, self.actual_len + ) + } +} + +/// MVP-2 / eval_runtime_mvp alignment: five stdout bytes denote runtime value `5`. +pub fn runtime_value_parse_rust(bytes: &[u8]) -> Result<(), RuntimeValueParseFailure> { + const EXPECTED: usize = 5; + if bytes.len() == EXPECTED { + Ok(()) + } else { + Err(RuntimeValueParseFailure { + expected_len: EXPECTED, + actual_len: bytes.len(), + }) + } +} + +struct BoundedChildOutput { + stdout: Vec, + stderr: Vec, + status: Option, + timed_out: bool, + stdout_truncated: bool, + stderr_truncated: bool, +} + +fn read_stream_bounded( + mut reader: R, + cap: usize, + phase: HostPhase, + stream: HostStream, +) -> Result<(Vec, bool), HostSetupFailure> { + let mut buf = [0u8; 8192]; + let mut out = Vec::new(); + let mut truncated = false; + loop { + if out.len() >= cap { + truncated = true; + break; + } + let room = cap - out.len(); + let chunk = room.min(buf.len()); + let n = match reader.read(&mut buf[..chunk]) { + Ok(0) => break, + Ok(n) => n, + Err(e) => { + return Err(HostSetupFailure::StreamReadFailed { + phase, + stream, + source: e.to_string(), + }); + } + }; + out.extend_from_slice(&buf[..n]); + } + Ok((out, truncated)) +} + +fn run_command_bounded( + mut cmd: Command, + timeout: Duration, + phase: HostPhase, +) -> Result { + cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); + let mut child = cmd.spawn().map_err(|e| HostSetupFailure::SpawnFailed { + phase, + source: e.to_string(), + })?; + let stdout_pipe = child + .stdout + .take() + .ok_or(HostSetupFailure::StdoutPipeMissing { phase })?; + let stderr_pipe = child + .stderr + .take() + .ok_or(HostSetupFailure::StderrPipeMissing { phase })?; + + let cap = HOST_STREAM_BYTE_CAP; + let (tx_out, rx_out) = mpsc::channel(); + let (tx_err, rx_err) = mpsc::channel(); + let phase_out = phase; + let phase_err = phase; + thread::spawn(move || { + let result = read_stream_bounded(stdout_pipe, cap, phase_out, HostStream::Stdout); + let _ = tx_out.send(result); + }); + thread::spawn(move || { + let result = read_stream_bounded(stderr_pipe, cap, phase_err, HostStream::Stderr); + let _ = tx_err.send(result); + }); + + let deadline = Instant::now() + timeout; + let mut timed_out = false; + let status = loop { + match child + .try_wait() + .map_err(|e| HostSetupFailure::TryWaitFailed { + phase, + source: e.to_string(), + })? { + Some(status) => break Some(status), + None if Instant::now() >= deadline => { + let _ = child.kill(); + let _ = child.wait(); + timed_out = true; + break None; + } + None => thread::sleep(Duration::from_millis(50)), + } + }; + + let recv_timeout = Duration::from_secs(5); + let (stdout, stdout_truncated) = + rx_out + .recv_timeout(recv_timeout) + .map_err(|e| HostSetupFailure::StreamReadFailed { + phase, + stream: HostStream::Stdout, + source: format!("recv: {e}"), + })??; + let (stderr, stderr_truncated) = + rx_err + .recv_timeout(recv_timeout) + .map_err(|e| HostSetupFailure::StreamReadFailed { + phase, + stream: HostStream::Stderr, + source: format!("recv: {e}"), + })??; + + Ok(BoundedChildOutput { + stdout, + stderr, + status, + timed_out, + stdout_truncated, + stderr_truncated, + }) +} + +fn bounded_output_to_log(output: &BoundedChildOutput, label: &str) -> BuildLog { + let mut lines = Vec::new(); + if !output.stdout.is_empty() { + lines.push(format!( + "{label} stdout: {}", + String::from_utf8_lossy(&output.stdout) + )); + } + if !output.stderr.is_empty() { + lines.push(format!( + "{label} stderr: {}", + String::from_utf8_lossy(&output.stderr) + )); + } + if output.stdout_truncated { + lines.push(format!( + "{label} stdout: truncated at {} bytes", + HOST_STREAM_BYTE_CAP + )); + } + if output.stderr_truncated { + lines.push(format!( + "{label} stderr: truncated at {} bytes", + HOST_STREAM_BYTE_CAP + )); + } + if output.timed_out { + lines.push(format!("{label} status: timed out")); + } else if let Some(status) = output.status { + lines.push(format!("{label} status: {status}")); + } else { + lines.push(format!("{label} status: unknown")); + } + BuildLog { lines } +} + +fn host_exit_from_bounded(output: &BoundedChildOutput, phase: HostPhase) -> HostExit { + if output.timed_out { + return HostExit::logical_violation(HostLogicalFailure::TimedOut { phase }); + } + let Some(status) = output.status else { + return HostExit::logical_violation(HostLogicalFailure::NoExitStatus { phase }); + }; + if status.success() { + HostExit::holds(status.code().unwrap_or(0)) + } else { + HostExit::logical_violation(HostLogicalFailure::ExitedNonzero { + phase, + code: status.code(), + }) + } +} + +/// Fail-closed when fixture pins are absent (W3 will require typed Node pins). +pub fn validate_emit_host_fixture_inputs( + inputs: &EmitHostFixtureInputs, +) -> Result<(), HostSetupFailure> { + if inputs.claim_input_root.is_empty() { + return Err(HostSetupFailure::EmptyClaimInputRoot); + } + if inputs.expected_eval_root.is_empty() { + return Err(HostSetupFailure::EmptyExpectedEvalRoot); + } + Ok(()) +} + +/// Compile `source` as a Rust binary crate in `work_dir`, run it, capture stdout/stderr. +pub fn run_emit_host_rust( + source: &str, + inputs: &EmitHostFixtureInputs, + work_dir: &Path, +) -> Result { + validate_emit_host_fixture_inputs(inputs)?; + fs::create_dir_all(work_dir).map_err(|e| HostSetupFailure::WorkDirCreateFailed { + source: e.to_string(), + })?; + let src_dir = work_dir.join("src"); + fs::create_dir_all(&src_dir).map_err(|e| HostSetupFailure::WorkDirCreateFailed { + source: e.to_string(), + })?; + + let cargo_toml = work_dir.join("Cargo.toml"); + let target_dir = work_dir.join("target"); + let manifest = "[package]\nname = \"emit_host_fixture\"\nversion = \"0.0.0\"\nedition = \"2021\"\n\n[[bin]]\nname = \"fixture\"\npath = \"src/main.rs\"\n"; + let mut f = + fs::File::create(&cargo_toml).map_err(|e| HostSetupFailure::ManifestWriteFailed { + source: e.to_string(), + })?; + f.write_all(manifest.as_bytes()) + .map_err(|e| HostSetupFailure::ManifestWriteFailed { + source: e.to_string(), + })?; + + let main_rs = src_dir.join("main.rs"); + fs::write(&main_rs, source).map_err(|e| HostSetupFailure::SourceWriteFailed { + source: e.to_string(), + })?; + + let mut build_cmd = Command::new("cargo"); + build_cmd + .args(["build", "--quiet", "--manifest-path"]) + .arg(&cargo_toml) + .env("CARGO_TARGET_DIR", &target_dir); + let build = run_command_bounded(build_cmd, HOST_BUILD_TIMEOUT, HostPhase::Build)?; + let build_log = bounded_output_to_log(&build, "build"); + if !matches!(build.status, Some(s) if s.success()) { + return Ok(EmitHostRunReceipt { + source_text: source.to_string(), + exit: host_exit_from_bounded(&build, HostPhase::Build), + stdout_bytes: build.stdout, + stderr_bytes: build.stderr, + build_log, + }); + } + + let bin_path = target_dir.join("debug/fixture"); + let run = run_command_bounded( + Command::new(&bin_path), + HOST_RUN_TIMEOUT, + HostPhase::FixtureRun, + )?; + let mut lines = build_log.lines; + lines.extend(bounded_output_to_log(&run, "run").lines); + Ok(EmitHostRunReceipt { + source_text: source.to_string(), + exit: host_exit_from_bounded(&run, HostPhase::FixtureRun), + stdout_bytes: run.stdout, + stderr_bytes: run.stderr, + build_log: BuildLog { lines }, + }) +} + +/// Default temp directory under `std::env::temp_dir()`. +pub fn default_work_dir(prefix: &str) -> PathBuf { + std::env::temp_dir().join(prefix) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn runtime_value_parse_rust_accepts_five_bytes() { + assert!(runtime_value_parse_rust(&[0, 0, 0, 0, 0]).is_ok()); + assert!(runtime_value_parse_rust(&[1, 2, 3]).is_err()); + } + + #[test] + fn validate_emit_host_fixture_inputs_rejects_empty_pins() { + assert!(matches!( + validate_emit_host_fixture_inputs(&EmitHostFixtureInputs { + claim_input_root: String::new(), + expected_eval_root: "x".into(), + }), + Err(HostSetupFailure::EmptyClaimInputRoot) + )); + } + + #[test] + fn host_exit_separates_setup_from_logical() { + let setup = HostExit::setup_rejected(HostSetupFailure::SpawnFailed { + phase: HostPhase::Build, + source: "e".into(), + }); + assert!(!setup.exit_holds()); + assert!(matches!( + setup.outcome, + HostExitOutcome::Rejected(HostSetupFailure::SpawnFailed { .. }) + )); + + let logical = HostExit::logical_violation(HostLogicalFailure::TimedOut { + phase: HostPhase::FixtureRun, + }); + assert!(!logical.exit_holds()); + assert!(matches!( + logical.outcome, + HostExitOutcome::Accepted(ExitWitness::Violates(_)) + )); + } + + #[test] + fn host_logical_run_only_on_holds() { + let exit = HostExit::holds(0); + let run = host_logical_run_from_exit(&exit, vec![1, 2, 3]).expect("holds"); + assert_eq!(run.stdout_bytes, vec![1, 2, 3]); + + let violated = HostExit::logical_violation(HostLogicalFailure::ExitedNonzero { + phase: HostPhase::FixtureRun, + code: Some(1), + }); + assert!(host_logical_run_from_exit(&violated, vec![]).is_none()); + } + + #[test] + fn run_command_bounded_times_out() { + let mut sleep_cmd = Command::new("sleep"); + sleep_cmd.arg("60"); + let out = run_command_bounded(sleep_cmd, Duration::from_millis(200), HostPhase::FixtureRun) + .expect("spawn sleep"); + assert!(out.timed_out, "expected timeout"); + let exit = host_exit_from_bounded(&out, HostPhase::FixtureRun); + assert!(matches!( + exit.outcome, + HostExitOutcome::Accepted(ExitWitness::Violates(HostLogicalFailure::TimedOut { + phase: HostPhase::FixtureRun + })) + )); + } +}