diff --git a/dag/gunbc/generic_binder_field_projection_deficit.dag b/dag/gunbc/generic_binder_field_projection_deficit.dag index fbc223d68d4..7f97999e7e2 100644 --- a/dag/gunbc/generic_binder_field_projection_deficit.dag +++ b/dag/gunbc/generic_binder_field_projection_deficit.dag @@ -77,7 +77,7 @@ data generic_binder_field_projection_sites: List = GenericBinderProjectionSite { site: decl_ref( module_path: "v2.test.claim.fold_lowering", - decl_name: "lowered_loop_binds_carrier_binder" + decl_name: "lowered_loop_carrier_is_a_generated_slot_not_the_authored_binder" ), index_coverage: OutsideDeclIndexTestWitnessModule, scrutinee_type: "Optional", diff --git a/dag/gunbc/non_fold_residue.dag b/dag/gunbc/non_fold_residue.dag index 9bd2b89160d..72c20789b62 100644 --- a/dag/gunbc/non_fold_residue.dag +++ b/dag/gunbc/non_fold_residue.dag @@ -175,6 +175,10 @@ data nfr_reason_typed_census_undetermined_node_kind: String = "the v1 checker le data nfr_reason_landed_after_typed_census: String = "a top-level wildcard arm over a closed coproduct that landed on main after the ca5ed1724b typed census, while that census's roster was still in review (#12980); the floor's diff-scoped typed walk named it on the roster PR's own run. Un-migrated modeling (DESIGN §6), rostered so the ratchet stays armed at unrostered=0" +data nfr_reason_eval_projection_receiver_not_aggregate: String = "field projection at eval: RuntimeAggregate is the one RuntimeValue variant that carries named fields, and every other variant refuses with the same located eval_rejected_projection_receiver_not_aggregate; enumerating them would clone one refusal arm per variant. Landed with gunbc#12506's field-projection eval route; declared at landing so the ratchet arms with the code" + +data nfr_dissolve_eval_projection_receiver_not_aggregate: DissolutionCondition = unbound_dissolution(description: "RuntimeValue exposes a typed field-bearing projection (derived from its declaration, dag/std/algebra) so eval reads fields without a per-variant match, and this row deletes") + data non_fold_residue_frontier: List = [ FrontierRow { subject: PathSubject { path: "dag/gunbc/instruments/fabric_control_plane_live_probe.dag::fci1_slot_prestate_equal" }, reason: nfr_reason_fci1_prestate_equal, dissolution: nfr_dissolve_fci1_prestate_equal }, FrontierRow { subject: PathSubject { path: "dag/gunbc/instruments/native_app_attest.dag::same_assertion_arm" }, reason: nfr_reason_native_app_attest_arm_equal, dissolution: nfr_dissolve_native_app_attest_arm_equal }, @@ -2014,6 +2018,11 @@ data non_fold_residue_frontier: List = [ FrontierRow { subject: PathSubject { path: "src/v2/compiler/body_lowering_fold.dag::body_lower_where_leaf_atom_optional" }, reason: nfr_reason_landed_after_typed_census, dissolution: nfr_dissolve_owning_fold }, FrontierRow { subject: PathSubject { path: "src/v2/compiler/body_lowering_fold.dag::body_lower_where_predicate" }, reason: nfr_reason_landed_after_typed_census, dissolution: nfr_dissolve_owning_fold }, FrontierRow { subject: PathSubject { path: "src/v2/std/qualified_name.dag::lexical_reference_label_optional" }, reason: nfr_reason_landed_after_typed_census, dissolution: nfr_dissolve_owning_fold }, + FrontierRow { + subject: PathSubject { path: "src/v2/compiler/05_eval.dag::eval_field_projection_of_receiver" }, + reason: nfr_reason_eval_projection_receiver_not_aggregate, + dissolution: nfr_dissolve_eval_projection_receiver_not_aggregate, + }, ] fn non_fold_residue_frontier_units() -> List { diff --git a/dag/gunbc/recurring_failure_mode/infer_child_context_cannot_depend_on_a_sibling_result.dag b/dag/gunbc/recurring_failure_mode/infer_child_context_cannot_depend_on_a_sibling_result.dag new file mode 100644 index 00000000000..c83c789205f --- /dev/null +++ b/dag/gunbc/recurring_failure_mode/infer_child_context_cannot_depend_on_a_sibling_result.dag @@ -0,0 +1,26 @@ +module gunbc.recurring_failure_mode.infer_child_context_cannot_depend_on_a_sibling_result + +import std.types { NonEmptyStr } +import std.decl_ref { DeclarationRef, WholeDeclaration } +import gunbc.recurring_failure_mode { RecurringFailureMode } + +data infer_child_context_cannot_depend_on_a_sibling_result: RecurringFailureMode = RecurringFailureMode { + identity: "infer_child_context_cannot_depend_on_a_sibling_result" as NonEmptyStr, + receipts: [ + "INVALID STATE: v2.compiler.infer infers every child subtree independently before its parent's behavior row executes. A Loop domain's SYNTHESIZED element type is required to instantiate the step member formal's fresh type variable, but no inference traversal can carry one child's settled result into another child's inference context. The fold encoding compounds it by storing the step BEFORE the domain (v2.compiler.fold_lowering fold_recurrence_encoding emits Positional step as child 0 and the named loop_domain_edge as child 1), so ordinary left-to-right child order cannot supply the relation either. HARM: the valid helper `fold(root.children, init: false, f: fn(found, e) { found || g_tree_has_arrow_body(root: e.target) })` is refused at `e.target` with infer_reason_projection_receiver_declares_no_fields -- `e` stays typed as its fresh variable instead of Edge, although the Loop domain carries `root.children: List` and Edge declares `target: Node`. The refusal is typed and located and nothing is fabricated, so this is a capability gap on the loud side of the ladder, never a silent wrong answer.", + "DISTINGUISHING FACTS: this is NOT a missing loop_domain_edge reader, NOT cross-module declaration retrieval, and NOT a projection-classification defect -- each of those was measured and excluded. v2.std.node fold_node carries SYNTHESIZED results only from child to parent (step: fn(R, Edge, R) -> R, whose third argument is the child's already-folded result, computed by a recursive call that receives nothing from the parent). v2.std.node fold_node_topdown carries INHERITED context only from parent to child, and its child_context: fn(Node, A, Edge) -> A receives the parent node, the inherited context and the current edge -- no folded sibling result. So neither algebra expresses `infer the domain child, derive the member instance, then infer the step child under it`, and converting infer's gather from NodeFold to NodeFoldTopDown is a DISPROVEN route rather than the trigger: it would change the shape of the stage's single walk across all behavior arms and still not carry the fact. Adding the role reader, the domain consumer or the List element relation before the traversal exists would make each declaration unreachable from any production verdict, which is the dangling modeling DESIGN section 3c forbids. The three facts the repair consumes already exist and are cited below: the fresh variable is minted by v2.std.anonymous_binder fresh_type_variable, the existing lambda-parameter route correctly derives the member formal AS that variable (v2.compiler.infer infer_lexical_reference_facts, reading the binding v2.compiler.resolve recorded in ResolvedTree.lexical_bindings), and the receiver rule that refuses is infer_projection_receiver -- so no second parameter-typing route may be introduced; the existing variable must be instantiated.", + "RUNG FOUND AT: mitigatable -- the compiler refuses rather than fabricating a field-bearing type, but valid fold programs cannot type. ATTAINABLE CEILING: structurally guaranteed -- the element relation is decidable from the domain's own type, and every fact it needs is already established by a stage that runs before the step subtree is judged; what is missing is one driver whose behavior-specific child schedule can infer a dependency child once, derive a scoped context from its settled result, and infer the dependent child once under that context. NEXT-RUNG TRIGGER, stated as the capability: an infer-local dependent-child driver schedules the Loop domain before the step, derives the collection-fold member instance from the domain type, extends a lexical TypeVariableInstance frame FOR THE STEP SUBTREE ONLY, and preserves the existing behavior rows, SUFFICIENT FOR the unchanged production helper to establish `e: Edge` and `e.target: Node`. Its discriminating red is a mutation deleting the domain-to-step context join, which must make that control fail. The element relation must be scoped by FOLD REALIZATION rather than by assuming every Loop is List, and the role authority (the step callable's actual 0 is the carrier and actual 1 is the domain member, stated today in v2.compiler.fold_lowering) belongs in one decoder rather than being re-read per consumer.", + ], + evidence: [ + DeclarationRef { module_path: "v2.compiler.infer", decl_name: "infer_entries_for_tree", field: WholeDeclaration }, + DeclarationRef { module_path: "v2.compiler.infer", decl_name: "infer_gather_fold_algebra", field: WholeDeclaration }, + DeclarationRef { module_path: "v2.compiler.infer", decl_name: "infer_gather_loop_row_on_entries", field: WholeDeclaration }, + DeclarationRef { module_path: "v2.std.node", decl_name: "fold_node", field: WholeDeclaration }, + DeclarationRef { module_path: "v2.std.node", decl_name: "fold_node_topdown", field: WholeDeclaration }, + DeclarationRef { module_path: "v2.compiler.fold_lowering", decl_name: "fold_recurrence_encoding", field: WholeDeclaration }, + DeclarationRef { module_path: "v2.test.parse.expression_bodied_fn_decl_parse", decl_name: "g_tree_has_arrow_body", field: WholeDeclaration }, + DeclarationRef { module_path: "v2.compiler.infer", decl_name: "infer_lexical_reference_facts", field: WholeDeclaration }, + DeclarationRef { module_path: "v2.compiler.infer", decl_name: "infer_projection_receiver", field: WholeDeclaration }, + DeclarationRef { module_path: "v2.std.anonymous_binder", decl_name: "fresh_type_variable", field: WholeDeclaration }, + ], +} diff --git a/dag/gunbc/rung_drop/match_arm_binder_typing_lost_to_lexical_carrier.dag b/dag/gunbc/rung_drop/match_arm_binder_typing_lost_to_lexical_carrier.dag new file mode 100644 index 00000000000..32824a23c71 --- /dev/null +++ b/dag/gunbc/rung_drop/match_arm_binder_typing_lost_to_lexical_carrier.dag @@ -0,0 +1,51 @@ +module gunbc.rung_drop.match_arm_binder_typing_lost_to_lexical_carrier + +import std.types { NonEmptyStr } +import gunbc.rung_drop { RungDrop, Standing, TypedDeclaration, DeletedWithoutReplacement } +import gunbc.guarantee_rung { Mitigatable, StructurallyGuaranteed } + +// THE DECLARED RUNG DROP for match-arm binder typing (DESIGN 4b(3)), landed in gunbc#12506 under +// calm-boar-904's ruling of 2026-10-02. +// +// WHAT IS DROPPED. gunbc#12641 typed a match-arm binder as the matched variant field's type at the +// scrutinee's instantiation, so `artifact.tree` inside `Accepted { value: artifact, ... } => ...` was a +// typed projection: an undeclared field off the binder refused, and the match was typed by its arms. That +// typing rode on v2.compiler.infer infer_parameter_scope_search's arm-binder variants. gunbc#12766 +// restricted that search to lambda parameters, and main's lexical-reference cut (gunbc#12947) deleted it, +// recording each binder's use in ResolvedTree.lexical_bindings instead -- with no declared type for a +// pattern binder. So today the binder's use is underived, the arm body that projects off it is accepted at +// the frontier with infer_match_arm_body_type_underived at its own locus, and the match is untyped. +// +// WHY StructurallyGuaranteed -> Mitigatable. Before, a field read off a match-arm binder that its variant +// does not declare was refused by the compiler from the modeled payload. Now it is not judged at all: the +// arm body is reported underived, typed and located, and nothing is fabricated -- the loud side of the +// ladder, below the rung it held. +// +// THE TRIGGER NAMES THE CAPABILITY, and the population rows below are its executing evidence: each asserts +// today's state EXACTLY (the binder's use underived, and in the match_binder rows the reason symbol at the +// binder projection's locus), so an unrelated refusal reds it rather than greening it, and each flips back to its +// #12641 assertion when the capability lands. +data match_arm_binder_typing_lost_to_lexical_carrier: RungDrop = RungDrop { + identity: "match_arm_binder_typing_lost_to_lexical_carrier" as NonEmptyStr, + + subject: "match-arm binder typing (gunbc#12641): a binder bound by a coproduct arm pattern is typed as its variant field's type at the scrutinee's instantiation, so projections off it are judged", + + declared: "2026-10-02", + + standing: Standing, + + declaration: TypedDeclaration { + previous: StructurallyGuaranteed, + temporary: Mitigatable, + reason: DeletedWithoutReplacement, + population: [ + "gunbc#12641 subject: v2.compiler.infer typing of match-arm binders through the scope search's arm-binder arms", + "v2.test.claim.match_binder.match_binder_typing mbt_binder_is_not_yet_typed_and_its_arm_body_is_reported_holds", + "v2.test.claim.match_binder.match_binder_typing mbt_match_is_not_yet_typed_at_the_binder_arm_holds", + "v2.test.claim.match_binder.match_binder_typing mbt_an_underived_arm_body_is_a_counted_frontier_holds", + "v2.test.claim.field_projection.field_projection_stages fps_a_match_binder_receiver_is_not_yet_typed_holds", + "v2.test.claim.field_projection.field_projection_stages fps_a_match_binder_projection_is_in_the_resolved_tree_holds", + ], + restoration_trigger: "match-arm binder typing through the lexical-binding carrier (N7-3): v2.compiler.resolve records a pattern binder's binding with the variant field it binds, and v2.compiler.infer types the binder's use from that binding at the scrutinee's instantiation, SUFFICIENT FOR mbt_binder_is_not_yet_typed_and_its_arm_body_is_reported to flip to the binder's use typed ParseArtifact, mbt_match_is_not_yet_typed_at_the_binder_arm to the match typed ParseTree, and a field off a match-arm binder its variant does not declare to refuse. Observed at retirement: those rows are flipped in the same change and pass on the required floor." + } +} diff --git a/dag/gunbc/rung_drop/roster.dag b/dag/gunbc/rung_drop/roster.dag index ad6e58e2fed..673109d1af0 100644 --- a/dag/gunbc/rung_drop/roster.dag +++ b/dag/gunbc/rung_drop/roster.dag @@ -121,6 +121,7 @@ import gunbc.rung_drop.typed_statement_let_refuses_until_the_bind_annotation_car import gunbc.rung_drop.python_to_typescript_compile_inhabitance_off_the_required_gate { python_to_typescript_compile_inhabitance_off_the_required_gate } import gunbc.rung_drop.edited_bin_witness_wet_rows_not_executed_by_ci { edited_bin_witness_wet_rows_not_executed_by_ci } import gunbc.rung_drop.shared_index_residency_asserted_after_the_run { shared_index_residency_asserted_after_the_run } +import gunbc.rung_drop.match_arm_binder_typing_lost_to_lexical_carrier { match_arm_binder_typing_lost_to_lexical_carrier } data rung_drop_roster: List = [ floor_cut_heal, @@ -225,6 +226,7 @@ data rung_drop_roster: List = [ shared_index_residency_asserted_after_the_run, dag_emit_round_trip_new_witness_eval_step_cost, dag_emit_real_grammar_round_trips_off_floor, + match_arm_binder_typing_lost_to_lexical_carrier, ] // THE DERIVATION THE PROJECTION USES, so "standing today" has one authority and not two. The diff --git a/docs/design-rung-drops.md b/docs/design-rung-drops.md index 13f99e3b565..faa792ebf9b 100644 --- a/docs/design-rung-drops.md +++ b/docs/design-rung-drops.md @@ -479,3 +479,7 @@ new-witness eval-step cost gate over the one claim that runs the dag target's re ### the dag target's per-construct real-grammar round trips (fn declaration, record type, coproduct, match, let, list, map literal, string escapes, caret symbol, quoted/bare/escaped keys): executed off the required floor by the named instrument //gunbc/instruments:dag-emit-real-grammar-round-trips instead of on it — declared 2026-10-01 the dag target's per-construct real-grammar round trips (fn declaration, record type, coproduct, match, let, list, map literal, string escapes, caret symbol, quoted/bare/escaped keys): executed off the required floor by the named instrument //gunbc/instruments:dag-emit-real-grammar-round-trips instead of on it: RUNG DROP, mechanically preventable -> mitigatable (replacement staged: a real-route dag parse cheap enough that each construct's two-parse round trip fits the per-claim budget). Population: //gunbc/instruments:dag-emit-real-grammar-round-trips case fn_declaration: the real-grammar round trip of this construct, run by the named instrument and not on the required floor, //gunbc/instruments:dag-emit-real-grammar-round-trips case record_type: the real-grammar round trip of this construct, run by the named instrument and not on the required floor, //gunbc/instruments:dag-emit-real-grammar-round-trips case coproduct: the real-grammar round trip of this construct, run by the named instrument and not on the required floor, //gunbc/instruments:dag-emit-real-grammar-round-trips case match_comma_arms: the real-grammar round trip of this construct, run by the named instrument and not on the required floor, //gunbc/instruments:dag-emit-real-grammar-round-trips case match_bool_literal_arms: the real-grammar round trip of this construct, run by the named instrument and not on the required floor, //gunbc/instruments:dag-emit-real-grammar-round-trips case match_statement_body_arm: the real-grammar round trip of this construct, run by the named instrument and not on the required floor, //gunbc/instruments:dag-emit-real-grammar-round-trips case let_statement: the real-grammar round trip of this construct, run by the named instrument and not on the required floor, //gunbc/instruments:dag-emit-real-grammar-round-trips case let_in: the real-grammar round trip of this construct, run by the named instrument and not on the required floor, //gunbc/instruments:dag-emit-real-grammar-round-trips case list_literal: the real-grammar round trip of this construct, run by the named instrument and not on the required floor, //gunbc/instruments:dag-emit-real-grammar-round-trips case map_literal: the real-grammar round trip of this construct, run by the named instrument and not on the required floor, //gunbc/instruments:dag-emit-real-grammar-round-trips case string_escapes: the real-grammar round trip of this construct, run by the named instrument and not on the required floor, //gunbc/instruments:dag-emit-real-grammar-round-trips case caret_symbol: the real-grammar round trip of this construct, run by the named instrument and not on the required floor, //gunbc/instruments:dag-emit-real-grammar-round-trips case escaped_quoted_key: the real-grammar round trip of this construct, run by the named instrument and not on the required floor, //gunbc/instruments:dag-emit-real-grammar-round-trips case quoted_key_spelled_like_an_identifier: the real-grammar round trip of this construct, run by the named instrument and not on the required floor, //gunbc/instruments:dag-emit-real-grammar-round-trips case bare_key: the real-grammar round trip of this construct, run by the named instrument and not on the required floor. Restored when: THE CAPABILITY: a real-route parse of each construct in this population (v2.compiler.parse parse_module_prepared over v2.compiler.program_assembly dag_prepared_grammar) fits the per-claim eval-step budget and wall clock that v2.workflow.required_floor derives, twice over, so that every member, run as a test fn on the floor, measures under the NewWitnessTier budget while still running the real parser on both sides. The parser's prepared choice plan (gunbc#11422) is one contributor and does not satisfy this trigger on its own. WHAT IT MUST BE SUFFICIENT FOR: EVERY member of the population, not one of them, re-enrolled as a test fn and passing on the floor. Re-enrolling a subset is a partial retirement and is recorded as one. Moving a member under another drop, or supplying its tree, does not satisfy this trigger. + +### match-arm binder typing (gunbc#12641): a binder bound by a coproduct arm pattern is typed as its variant field's type at the scrutinee's instantiation, so projections off it are judged — declared 2026-10-02 + +match-arm binder typing (gunbc#12641): a binder bound by a coproduct arm pattern is typed as its variant field's type at the scrutinee's instantiation, so projections off it are judged: RUNG DROP, structurally guaranteed -> mitigatable (deleted without replacement). Population: gunbc#12641 subject: v2.compiler.infer typing of match-arm binders through the scope search's arm-binder arms, v2.test.claim.match_binder.match_binder_typing mbt_binder_is_not_yet_typed_and_its_arm_body_is_reported_holds, v2.test.claim.match_binder.match_binder_typing mbt_match_is_not_yet_typed_at_the_binder_arm_holds, v2.test.claim.match_binder.match_binder_typing mbt_an_underived_arm_body_is_a_counted_frontier_holds, v2.test.claim.field_projection.field_projection_stages fps_a_match_binder_receiver_is_not_yet_typed_holds, v2.test.claim.field_projection.field_projection_stages fps_a_match_binder_projection_is_in_the_resolved_tree_holds. Restored when: match-arm binder typing through the lexical-binding carrier (N7-3): v2.compiler.resolve records a pattern binder's binding with the variant field it binds, and v2.compiler.infer types the binder's use from that binding at the scrutinee's instantiation, SUFFICIENT FOR mbt_binder_is_not_yet_typed_and_its_arm_body_is_reported to flip to the binder's use typed ParseArtifact, mbt_match_is_not_yet_typed_at_the_binder_arm to the match typed ParseTree, and a field off a match-arm binder its variant does not declare to refuse. Observed at retirement: those rows are flipped in the same change and pass on the required floor. diff --git a/src/v2/compiler/00_compile.dag b/src/v2/compiler/00_compile.dag index 625dc4f660e..f618f89cece 100644 --- a/src/v2/compiler/00_compile.dag +++ b/src/v2/compiler/00_compile.dag @@ -42,6 +42,7 @@ import v2.compiler.emit { emit } import v2.compiler.eval { eval, eval_node, eval_default_interpretation, inputs_root_only } import v2.compiler.infer { InferredTree } import v2.compiler.name_resolve { + closure_declarations_demand, Admission, ResolutionSubject, ResolutionContext, @@ -3271,6 +3272,8 @@ fn native_import_target_refusal_diagnostics(target: NativeTestFileRefusal, resol } } +// THE CLOSURE INDEX IS DEMANDED ON THE SHARED CONTEXT AND CARRIED FORWARD on the returned step, so the +// next module of the lane reads it instead of re-resolving the closure (review 74212). fn native_module_resolve_verdict( context: NativeTestContext, refusal_index: Map, @@ -3285,27 +3288,44 @@ fn native_module_resolve_verdict( context: context, module: qualified_name_from_dotted_string(dotted: module) ) - NativeModuleResolveStep { - verdict: match step.walk { - ResolveWalkRefused { first: f, rest: r, observation: o } => - match native_import_target_file_refusal(context: step.context, module: qualified_name_from_dotted_string(dotted: module)) { + match step.walk { + ResolveWalkRefused { first: f, rest: r, observation: o } => + NativeModuleResolveStep { + verdict: match native_import_target_file_refusal(context: step.context, module: qualified_name_from_dotted_string(dotted: module)) { Present { value: target } => NativeModuleResolveImportTargetFileRefused { target: target, first: f, rest: r, observation: o } Absent => NativeModuleResolveRefused { first: f, rest: r, observation: o } - } - ResolveWalkAccepted { value: resolved, diagnostics: _, lexical: lexical } => - match step.context.resolution { - Accepted { value: shared, diagnostics: _ } => - NativeModuleResolveAccepted { resolved: resolved_tree_of(root: resolved, symbol_index: shared.symbol_index, lexical: lexical) } - Rejected { diagnostics: r } => - NativeModuleResolveRefused { + }, + context: step.context + } + ResolveWalkAccepted { value: resolved, diagnostics: _, lexical: lexical } => + match step.context.resolution { + Accepted { value: shared, diagnostics: d } => + let closure = closure_declarations_demand(shared: shared) + NativeModuleResolveStep { + verdict: NativeModuleResolveAccepted { + resolved: resolved_tree_of( + root: resolved, + symbol_index: shared.symbol_index, + closure_declarations: closure.declarations, + lexical: lexical + ) + }, + context: native_test_context_with_resolution( + context: step.context, + resolution: Accepted { value: closure.context, diagnostics: d } + ) + } + Rejected { diagnostics: r } => + NativeModuleResolveStep { + verdict: NativeModuleResolveRefused { first: r, rest: [], observation: ObservationIncomplete { reason: ^resolve_observation_context_refused } - } - } - }, - context: step.context + }, + context: step.context + } + } } } } diff --git a/src/v2/compiler/03_name_resolve.dag b/src/v2/compiler/03_name_resolve.dag index f8dbc9f222e..b33e9a2606c 100644 --- a/src/v2/compiler/03_name_resolve.dag +++ b/src/v2/compiler/03_name_resolve.dag @@ -2,7 +2,7 @@ module v2.compiler.name_resolve import v2.std.language_model { LanguageModel } -import v2.compiler.symbol_index_fill { symbol_index_fill_module_roots } +import v2.compiler.symbol_index_fill { symbol_index_fill_module_declarations, symbol_index_fill_module_roots } import v2.compiler.normalized_tree { NormalizedTree, normalized_tree_roots_to_binding_sources } import v2.std.declaration_marker { TestCodeIndex, test_code_index_add_module, test_code_index_empty } import v2.std.symbol_index { SymbolIndex, empty_symbol_index } @@ -35,6 +35,7 @@ import v2.compiler.parse { parse_tree_projection_edge } import std.algebra { Cons, Empty, FreeMonoid } import v2.std.algebra { fold_list } import v2.std.collection { Absent, Map, Present, List, PointwisePower, empty_map, map_get, map_lookup, map_insert, optional_absent, optional_present } +import v2.std.optional { Optional } import v2.std.diagnostic { Accepted, Diagnostic, @@ -145,6 +146,7 @@ type ResolutionContext { policy: NameResolutionPolicy namespaces: Map namespaces_built: Int + closure_declarations: Optional } // One stored production: the admission it was produced under, and its outcome -- a refusal is @@ -198,7 +200,8 @@ fn resolution_context_of_validated( active_roots: active_roots, policy: policy, namespaces: empty_map(), - namespaces_built: 0 + namespaces_built: 0, + closure_declarations: optional_absent() } } @@ -724,7 +727,8 @@ fn resolution_context_namespace( key: admission.subject.name, value: NamespaceProviderEntry { admission: admission, admitted: admitted } ), - namespaces_built: shared.namespaces_built + 1 + namespaces_built: shared.namespaces_built + 1, + closure_declarations: shared.closure_declarations } } } @@ -874,10 +878,104 @@ fn resolve_in_context(context: Outcome, admission: Admission) let walked = resolve_walk_in_context(context: context, admission: admission) match walked.context { Rejected { diagnostics: r } => ContextResolved { resolved: Rejected { diagnostics: r }, context: walked.context } - Accepted { value: shared, diagnostics: _ } => + Accepted { value: shared, diagnostics: d } => + let closure = closure_declarations_demand(shared: shared) ContextResolved { - resolved: resolved_tree_outcome(w: walked.walk, symbol_index: shared.symbol_index), - context: walked.context + resolved: resolved_tree_outcome( + w: walked.walk, + symbol_index: shared.symbol_index, + closure_declarations: closure.declarations + ), + context: Accepted { value: closure.context, diagnostics: d } + } + } +} + +// EVERY ROOT IN THE CLOSURE, RESOLVED ONCE PER CONTEXT, so inference can retrieve a declaration an imported +// provider owns. v2.compiler.resolve resolved_declarations_of walks ONE root, so the subject's index held only +// the subject's declarations and every cross-module field read refused -- measured as +// fps_a_cross_module_record_projection_infers against a passing same-module control. +// +// ONCE PER CONTEXT, NOT ONCE PER SUBJECT. Every subject a context resolves demands the same closure index, +// and the shared ResolutionContext is their least common ancestor (DESIGN section 2), so the index is +// produced on the first demand and carried on the context like the namespace provider beside it; a later +// subject reads it. Recomputing it per subject made a lane of N modules do N x (N + 1) resolves, and folding +// the roots per subject was a second quadratic term (review 74212). The per-root resolves thread the context +// they are given, so the namespaces they admit are kept as well. +// +// THE RECURSION IS CUT BY THE PER-ROOT RESOLVES NOT ASKING FOR A CLOSURE: each provider is walked by +// resolve_walk_in_context exactly as any subject is, and only resolved_tree_of reads the collected index. A +// provider does not need the closure index to produce the declarations this fold reads from it. +// +// A REFUSED ROOT CONTRIBUTES NOTHING AND DOES NOT FAIL THE FOLD. That is deliberate and is NOT an absorbing +// fallback: it does not widen an answer or substitute a reading. A provider that cannot resolve simply has +// no resolved declarations to offer, so a receiver typed by one of its records refuses at +// infer_reason_projection_receiver_declaration_unavailable -- the located refusal that names missing +// evidence. +// +// THIS IS THE ONE-SEAT FORM OF A DEPENDENCY THE DEMAND ENGINE OWNS. Infer(module) depends on +// Resolve(module) AND Resolve(each provider it consumes); here that is discharged by resolving the whole +// SELECTED closure, which is broader than the import closure. Narrowing it to the exact provider set changes +// WHICH roots are folded and not the inference rule. +// +// RECORD AND RESOURCE PAYLOAD MARKS ARE NOT CARRIED HERE, AND THAT IS INHERITED RATHER THAN CHOSEN: a resolved +// Node does not carry the normalize-time record roster, so each root is filled with Empty carriers, and +// v2.std.symbol_index symbol_index_declared_payload_at answers Absent for a RECORD in this index. The +// projection path reads through symbol_index_lookup and is unaffected. +type ClosureDeclarations { + declarations: SymbolIndex + context: ResolutionContext +} + +type ClosureDeclarationsFold { + index: SymbolIndex + context: ResolutionContext +} + +fn closure_declarations_demand(shared: ResolutionContext) -> ClosureDeclarations { + match shared.closure_declarations { + Present { value: index } => ClosureDeclarations { declarations: index, context: shared } + Absent => + let built = fold_list( + xs: shared.roots.roots, + empty: ClosureDeclarationsFold { index: empty_symbol_index(), context: shared }, + cons: fn(acc, nt) { + match qualified_name_from_module_node(root: nt.root) { + Rejected { diagnostics: _ } => acc + Accepted { value: module_qn, diagnostics: _ } => + let walked = resolve_walk_in_context( + context: Accepted { value: acc.context, diagnostics: None }, + admission: Admission { subject: ResolutionSubject { name: module_qn }, imports: [] } + ) + let next_context = match walked.context { + Accepted { value: c, diagnostics: _ } => c + Rejected { diagnostics: _ } => acc.context + } + match walked.walk { + ResolveWalkAccepted { value: resolved, diagnostics: _, lexical: _ } => + ClosureDeclarationsFold { + index: symbol_index_fill_module_declarations(index: acc.index, root: resolved, record_declarations: Empty, resource_declarations: Empty), + context: next_context + } + ResolveWalkRefused { first: _, rest: _, observation: _ } => + ClosureDeclarationsFold { index: acc.index, context: next_context } + } + } + } + ) + ClosureDeclarations { + declarations: built.index, + context: ResolutionContext { + lm: built.context.lm, + roots: built.context.roots, + symbol_index: built.context.symbol_index, + index: built.context.index, + active_roots: built.context.active_roots, + policy: built.context.policy, + namespaces: built.context.namespaces, + namespaces_built: built.context.namespaces_built, + closure_declarations: optional_present(value: built.index) + } } } } diff --git a/src/v2/compiler/03_resolve.dag b/src/v2/compiler/03_resolve.dag index 2c2597a3014..0839bfe97f4 100644 --- a/src/v2/compiler/03_resolve.dag +++ b/src/v2/compiler/03_resolve.dag @@ -82,9 +82,10 @@ import v2.std.qualified_name { qualified_name_to_dotted_string, qualified_name_snoc, declaration_reference_node, + declaration_reference_path_optional, parameter_reference_node, - lexical_reference_node, - declaration_reference_path_optional + qualified_name_spine_segment_nodes, + lexical_reference_node } import v2.std.resolution_policy { ImportScoped, @@ -155,6 +156,11 @@ import v2.std.node { symbol_lexeme, labeled_named_is, well_formed, + Loop, + LoopBoundMeasure, + LoopCarrierBinder, + LoopRealizedDeclaration, + loop_edge_role, MatchArmPatternEdge, ArrowSignatureOrderEdge, edge_is_core, @@ -181,6 +187,7 @@ import v2.std.node_query { ConstructTagElided, NotAConstruct, construct_tag_optional, + field_projection_node, node_positional_child_targets, find_named_child, is_wildcard_pattern, @@ -241,11 +248,33 @@ fn resolved_declarations_of(root: Node) -> SymbolIndex { // THE ONE CONSTRUCTOR of an accepted resolution's carrier: the root, the index it was resolved // against, its declarations as resolved, and the lexical bindings its walk recorded. -fn resolved_tree_of(root: Node, symbol_index: SymbolIndex, lexical: List) -> ResolvedTree { +// THE INDEX INFERENCE READS SPANS THE CLOSURE, NOT ONE MODULE, and that is the capability this constructor +// previously could not supply. resolved_declarations_of walks ONE resolved root -- the subject's -- so a +// record an imported provider declares was absent from it BY CONSTRUCTION, and v2.compiler.infer +// infer_projection_receiver could not retrieve the declaration for any imported receiver type. +// +// MEASURED, one variable moved: v2.test.claim.field_projection.field_projection_stages +// fps_a_same_module_record_projection_infers passed while fps_a_cross_module_record_projection_infers failed +// over byte-identical records projected off a plain parameter. Two further rows separated the cause from its +// neighbours -- fps_dbg_same_module_in_two_root_ingest PASSES, so a multi-root ingest is not the defect and +// the subject really is cross-module retrieval. +// +// SO THE CALLER SUPPLIES THE CLOSURE'S RESOLVED DECLARATIONS, which keeps this a constructor rather than +// making it a second resolver: the facts come from the resolutions that actually happened, and whoever +// performed them owns them. The alternative considered and REFUSED was reading +// ResolutionContext.symbol_index instead, which is filled from NORMALIZED roots: it would have made +// imported field typing appear to work off AUTHORED declarations rather than resolved ones, which is the +// authored/resolved fork this carrier exists to keep apart (see the resolved_declarations header above). +fn resolved_tree_of( + root: Node, + symbol_index: SymbolIndex, + closure_declarations: SymbolIndex, + lexical: List +) -> ResolvedTree { ResolvedTree { root: root, symbol_index: symbol_index, - resolved_declarations: resolved_declarations_of(root: root), + resolved_declarations: resolved_declarations_over(closure: closure_declarations, subject: root), lexical_bindings: fold(lexical, init: empty_map(), f: fn(m, e) { map_insert(m, e.reference, e.binding) }) } } @@ -261,6 +290,23 @@ fn resolved_tree_lexical_binding(tree: ResolvedTree, reference: Node) -> Optiona } } +// THE CLOSURE INDEX IS PRODUCED ONCE PER CONTEXT (v2.compiler.name_resolve closure_declarations_demand) and +// only the SUBJECT is filled here. symbol_index_fill_module_declarations keys every declaration at its OWN +// module's qualified name, so filling the subject over the closure index is total and order-independent. +// +// THE SUBJECT IS FILLED UNCONDITIONALLY. It is normally already in the closure index, but a caller that holds +// no closure passes empty_symbol_index(), and this still answers exactly what it answered before: the +// subject's own declarations. So the change cannot take a capability away from a caller that has not yet been +// given the closure. +fn resolved_declarations_over(closure: SymbolIndex, subject: Node) -> SymbolIndex { + symbol_index_fill_module_declarations( + index: closure, + root: subject, + record_declarations: Empty, + resource_declarations: Empty + ) +} + // `test_code`, `declared_in` and `imported_origins` exist for one decision: whether a reference binds // to test code (owner ruling 2026-09-16/17). Root-scope bindings come from exactly two sources: the // subject's own declarations, which live under `declared_in`, and admitted imports, whose origin @@ -388,6 +434,192 @@ fn try_edge_declared_binding(e: Edge) -> DeclaredBinding? { } } +// THE ONE ADMISSION FOR A VALUE BINDER, whatever spelling introduced it: an Arrow's parameters, a +// `let`, a match-arm binder, a loop carrier. Before this, each site harvested its own names straight +// into a ScopeFrame and no site judged them, so two questions had no owner at all -- whether a frame +// binds one name twice, and whether a binder hides a value that is already visible. The type-parameter +// frame already answered the second for TYPE binders +// (resolve_reason_type_parameter_shadows_visible_name, refused at the binder); this is the same +// discipline for value binders, in one place rather than four. +// +// WHAT COUNTS AS HIDING IS A LEXICAL FACT, AND ONLY A LEXICAL FACT. The chain answers BoundInFrame for +// an enclosing binder and BoundAtRoot for a module-namespace binding (lookup_chain / ScopeBinding), and +// ONLY BoundInFrame refuses. So a binder spelled like a declaration in some other module -- imported or +// not -- is admitted: an unrelated declaration elsewhere in the corpus may not decide whether a local +// binder is legal, which is the defect this slice was opened on. A binder spelled like a name at the +// module root is likewise admitted; shadowing a root declaration is ordinary, hiding an enclosing +// BINDER is not. +// +// IT RETURNS A VERDICT RATHER THAN A MAP, so a refusal is located at the binder and cannot be silently +// widened into an empty frame -- an empty frame would leave the binder unbound and send its uses to the +// global lookup, which is exactly how `found` in a fold step came to be read as a name declared in +// several modules. +type BinderAdmission + = BindersAdmitted { locals: Map } + | BinderDuplicateInFrame { binder: Symbol } + | BinderHidesVisibleValue { binder: Symbol } + +fn admit_value_binders(names: List, outer: Scope) -> BinderAdmission { + fold(names, init: BindersAdmitted { locals: empty_map() }, f: fn(acc, name) { + match acc { + BinderDuplicateInFrame { binder: b } => BinderDuplicateInFrame { binder: b } + BinderHidesVisibleValue { binder: b } => BinderHidesVisibleValue { binder: b } + BindersAdmitted { locals: m } => + match v2.std.collection.map_lookup(m: m, key: name) { + Present { value: _ } => BinderDuplicateInFrame { binder: name } + Absent => admit_one_value_binder(locals: m, name: name, outer: outer) + } + } + }) +} + +fn admit_one_value_binder(locals: Map, name: Symbol, outer: Scope) -> BinderAdmission { + match lookup_chain(s: outer, name: name) { + Rejected { diagnostics: _ } => BindersAdmitted { locals: map_insert(locals, name, name) } + Accepted { value: bound, diagnostics: _ } => + match bound { + BoundInFrame { canonical: _, kind: _ } => BinderHidesVisibleValue { binder: name } + BoundAtRoot { canonical: _ } => BindersAdmitted { locals: map_insert(locals, name, name) } + ScopeUnbound => BindersAdmitted { locals: map_insert(locals, name, name) } + } + } +} + +fn binder_duplicate_in_frame_diagnostic(binder: Symbol, at: Node) -> Diagnostic { + Diagnostic { + reason: ^resolve_reason_binder_duplicate_in_frame, + at: node_locus(node: at), + correction: Unavailable { reason: ExternalContractUnknown } + } +} + +fn binder_hides_visible_value_diagnostic(binder: Symbol, at: Node) -> Diagnostic { + Diagnostic { + reason: ^resolve_reason_binder_hides_visible_value, + at: node_locus(node: at), + correction: Unavailable { reason: ExternalContractUnknown } + } +} + +// THE NAMES EACH BINDER SITE INTRODUCES, as LISTS rather than maps, because a map cannot answer the +// question the admission asks. map_insert silently overwrites, so a frame built by insertion cannot tell +// a duplicate binder from a single one -- the duplicate is exactly what must refuse. These collectors +// mirror the frame builders beside them (arrow_domain_frame_binders, resolve_pattern_binders, +// bind_frame_binders): an Arrow's list is the AUTHORED Named binders of its domain Conj, the same edges +// arrow_domain_frame_binders binds. +fn arrow_domain_binder_name_list(n: Node) -> List { + match n.kind { + TypeNode { connective: Arrow } => + match arrow_first_positional_target(children: n.children) { + FirstPositionalFound { target: domain } => conj_named_binder_name_list(root: domain) + FirstPositionalAbsent => [] + } + TypeNode { connective: _ } => [] + ComputationNode { behavior: _ } => [] + } +} + +fn conj_named_binder_name_list(root: Node) -> List { + match root.kind { + TypeNode { connective: Conj } => + fold(root.children, init: [], f: fn(acc, e) { + match try_edge_declared_binding(e: e) { + Present { value: b } => list_snoc_item(xs: acc, item: b.name) + Absent => acc + } + }) + TypeNode { connective: _ } => [] + ComputationNode { behavior: _ } => [] + } +} + +// A match arm's list mirrors resolve_pattern_binders exactly, including its two exclusions: a wildcard +// binds nothing, and a field target that names a CONSTRUCTOR is a nested pattern, not a binder. +fn pattern_binder_name_list(ctx: ResolveContext, pat: Node) -> List { + match construct_tag_optional(n: pat) { + Absent => [] + Present { value: _ } => + fold(construct_field_edges(n: pat), init: [], f: fn(acc, e) { + match e.target.kind { + TypeNode { connective: Atom { identity: id } } => + if is_wildcard_pattern(pattern: e.target) { + acc + } else if resolve_pattern_atom_names_constructor(ctx: ctx, id: id) { + acc + } else { + list_snoc_item(xs: acc, item: id) + } + TypeNode { connective: _ } => concat(acc, pattern_binder_name_list(ctx: ctx, pat: e.target)) + ComputationNode { behavior: _ } => concat(acc, pattern_binder_name_list(ctx: ctx, pat: e.target)) + } + }) + } +} + +// A `let`'s list mirrors bind_frame_binders: the binder is the FIRST positional child, and that +// subtree's atoms are collected. For an ordinary `let x = e` that subtree is the single binder atom. +fn bind_binder_name_list(n: Node) -> List { + match n.kind { + ComputationNode { behavior: Bind } => + if positional_child_count(children: n.children) == 3 { + match first(positional_edges(children: n.children)) { + Present { value: e0 } => atom_name_list(root: e0.target) + Absent => [] + } + } else { + [] + } + TypeNode { connective: _ } => [] + ComputationNode { behavior: _ } => [] + } +} + +fn atom_name_list(root: Node) -> List { + fold_node( + n: root, + algebra: NodeFold { + init: fn(n0) { + match n0.kind { + TypeNode { connective: Atom { identity: sym } } => [sym] + TypeNode { connective: _ } => [] + ComputationNode { behavior: _ } => [] + } + }, + step: fn(acc, _e, child) { concat(acc, child) } + } + ) +} + +// THE ADMISSION AS A GATE OVER A SITE'S OWN FRAME. Each site keeps the frame its harvester already built +// -- so an admitted binder resolves exactly as it did before this change -- and the gate only decides +// whether the site proceeds at all. That separation is deliberate: it makes the new behaviour purely a +// set of refusals, with no chance of silently changing which name a body binds to. +type BinderGate + = BinderGateAdmitted + | BinderGateRefused { diagnostic: Diagnostic } + +fn gate_value_binders(names: List, outer: Scope, at: Node) -> BinderGate { + match admit_value_binders(names: names, outer: outer) { + BindersAdmitted { locals: _ } => BinderGateAdmitted + BinderDuplicateInFrame { binder: b } => + BinderGateRefused { diagnostic: binder_duplicate_in_frame_diagnostic(binder: b, at: at) } + BinderHidesVisibleValue { binder: b } => + BinderGateRefused { diagnostic: binder_hides_visible_value_diagnostic(binder: b, at: at) } + } +} + +// A LOOP'S CARRIER IS A BINDER, SO IT OPENS A FRAME AND IS NOT WALKED AS A REFERENCE. v2.std.node says +// it outright -- "^loop_carrier_edge targets the loop-carried state's BINDER, never a value" -- and +// resolve did neither: a Loop fell to resolve_children_homogeneous_scope, which opens a frame only for +// an Arrow domain, so the carrier bound nothing and the iterated body's uses of it reached the bare-name +// census. That is the whole of the `found` refusal in `fold(xs, init: false, f: fn(found, e) { ... })`: +// v2.compiler.fold_lowering destructures the step literal into an iteration body and ONE carrier symbol +// and builds this Loop, so the step's Arrow is never constructed and its second binder is dropped. +// +// The carrier edge is carried UNWALKED for the same reason the Arrow's declared-order edge is +// (resolve_arrow_node_in): its target names a binder, not a reference, so resolving it would ask the +// scope to answer for a name the scope is being opened to introduce. + fn harvest_conj_named_bindings(root: Node, acc: Map) -> Map { match root.kind { TypeNode { connective: Conj } => @@ -1081,85 +1313,209 @@ fn lookup_symbol_index_atom_identity( // control (`Bool` has no child `v`), and the positive `Rec.v` specimen needs declaration grafting // as well as this projection and is owned by those lanes. This check only closes the fail-open // arm beside the projection. -fn qualified_head_bound_on_chain(ctx: ResolveContext, path: QualifiedName) -> Outcome { +// THE DECISION BETWEEN A DECLARATION PATH AND A VALUE PROJECTION, CARRIED AT THE GRAIN THAT DECIDES IT. +// This answered Bool, collapsing BoundInFrame and BoundAtRoot to `true`, and that Bool was the SOLE +// discriminator its caller used to choose between reading a dotted path as a field projection and reading +// it as a qualified declaration name. The two questions are different: "is this head bound anywhere on the +// chain" is not "is this head a lexical value a field can be projected from". A module-level name bound at +// the root is a DECLARATION or a namespace segment, so a path THROUGH it is a qualified name. +// +// NO NATIVE RECEIPT IS CLAIMED FOR THIS CHANGE, and an earlier revision of this header claimed one that +// was false. It attributed the native refusal of v2.test.parse.expression_bodied_fn_decl_parse to a +// fully-qualified annotation being read as nested field projections through this collapsed Bool. That was +// read off a diagnostic CHAIN HEAD and was wrong twice: the native eight measured identically before and +// after the change, and the actual cause entry in that chain anchors a genuine two-edge field projection +// (`e.target`) whose base is a fold-step binder -- a subject this function never sees. The specimen is an +// inference-side join, not a resolution one. +// +// WHAT JUSTIFIES THE CHANGE IS THE READING ITSELF, which needs no incident: one Bool cannot carry a +// discriminator its consumer must make between two readings, and a consumer forced to re-derive what it was +// handed is DESIGN section 5's validation standing where construction was available. The controls in +// v2.test.claim.field_projection.field_projection_stages establish the rule; no claim is made here about the +// eight, about wall 3d, or about any native refusal consuming this arm. +// +// WHY THE ANSWER IS ScopeBinding AND NOT A SECOND Bool. lookup_chain already computes the distinction and +// already returns it; collapsing it here and then trying to recover it downstream is what forced the +// earlier repair attempt into the wrong link -- a `projects_fields` gate inside the projection builder, +// which could only turn the case into an unbound refusal because the declaration door is in the CALLER and +// not reachable from there. Carrying the value this function was handed is the construction; re-deriving it +// later was the validation standing where construction was available. +fn qualified_head_scope_binding(ctx: ResolveContext, path: QualifiedName) -> Outcome { match path { - Cons { head: head, tail: _ } => - match lookup_chain(s: ctx.scope, name: head) { - Rejected { diagnostics: r } => Rejected { diagnostics: r } - Accepted { value: found, diagnostics: d } => - Accepted { - value: (match found { - BoundInFrame { canonical: _, kind: _ } => true - BoundAtRoot { canonical: _ } => true - ScopeUnbound => false - }), - diagnostics: d + Cons { head: head, tail: _ } => lookup_chain(s: ctx.scope, name: head) + Empty => Accepted { value: ScopeUnbound, diagnostics: None } + } +} + +// THE PROJECTION THIS ARM COULD NOT PERFORM, now performed. A dotted chain whose HEAD is bound on the +// scope chain and whose whole path names no declaration is not an unbound name: it is a field access +// off that binding. Body lowering deliberately declines to decide -- v2.compiler.body_lowering_fold +// PostfixAccum keeps `a.b.c` as one spine and says why: "deciding here would be a second resolver with +// no scope to consult", naming THIS function as the decider. So the spine arriving here is not a +// producer defect; answering Unbound for it was this function accepting the job and not doing it, and +// the two causes -- a bound head needing projection, and a genuinely unbound head -- were reported +// identically. +// +// THE HEAD BECOMES ITS BINDER, THE REST BECOME SUCCESSIVE PROJECTIONS, left to right, which is the +// same association the postfix lowering gives a projection off a value: `b.x.y` is `(b.x).y`. Each +// step is v2.std.node_query field_projection_node over the segment's OWN node, so every field keeps +// the occurrence of the token it was lowered from and a later diagnostic about one field lands on that +// field rather than on the whole chain -- which is why this reads segment NODES +// (qualified_name_spine_segment_nodes) rather than rebuilding atoms from the name's symbols. +// +// WHAT THIS DOES NOT DECIDE, and must not: whether the projected field EXISTS on the receiver's type. +// That check needs a type, resolve has none, and v2.std.node_query field_projection_node's own header +// already assigns it to the stage over this node. So this arm is structural: it commits to the reading +// "field access", and a field no type declares is refused later by the stage that can see the type. +// Admitting the shape is therefore not admitting the access, and the absent-field control in +// v2.test.claim.field_projection.field_projection_stages is what holds that line. +// +// THE HEAD IS RESOLVED AS THE FRAME-LOCAL BINDER IT IS, through canonical_atom on the canonical symbol +// lookup_chain returned -- the same producer every other bound bare use goes through, so a projection +// base is not a second spelling of a binder reference. +fn resolve_bound_head_projection( + ctx: ResolveContext, + n: Node, + pending: Diagnostics +) -> Outcome { + match qualified_name_spine_segment_nodes(root: n) { + Absent => + Rejected { + diagnostics: rejected_with_pending( + pending: pending, + rejected: diagnostics_singleton(d: unbound_symbol_diagnostic(n: n)) + ) + } + Present { value: segments } => + match segments { + Empty => + Rejected { + diagnostics: rejected_with_pending( + pending: pending, + rejected: diagnostics_singleton(d: unbound_symbol_diagnostic(n: n)) + ) + } + Cons { head: head_segment, tail: field_segments } => + match resolve_projection_base(ctx: ctx, head_segment: head_segment) { + Absent => + Rejected { + diagnostics: rejected_with_pending( + pending: pending, + rejected: diagnostics_singleton(d: unbound_symbol_diagnostic(n: n)) + ) + } + Present { value: Rejected { diagnostics: r } } => + Rejected { diagnostics: rejected_with_pending(pending: pending, rejected: r) } + Present { value: Accepted { value: base, diagnostics: _ } } => + outcome_with_diagnostics( + value: fold_list( + xs: field_segments, + empty: base, + cons: fn(acc, field_segment) { + field_projection_node(base: acc, field: field_segment, source: n) + } + ), + diagnostics: pending + ) } } - Empty => Accepted { value: false, diagnostics: None } } } +// THE BASE OF A PROJECTION IS WHATEVER THE HEAD SEGMENT IS BOUND TO, AND THIS FUNCTION NO LONGER DECIDES +// WHETHER A PROJECTION IS THE RIGHT READING AT ALL. That decision is resolve_dotted_path_reading's, made +// from the head's ScopeBinding and the corpus's declarations before this is ever called, so by the time a +// head segment arrives here the caller has already committed to the projection reading and this function's +// only job is to produce the base node. +// +// A `projects_fields` GATE STOOD HERE AND WAS THE WRONG LINK, recorded because the reasoning that put it +// here is the reasoning worth not repeating. It refused a root-bound head whenever field segments followed, +// which looks like the same rule the caller now applies and is not: refusing HERE can only produce an +// unbound-symbol diagnostic, because the declaration-path door is in the caller and unreachable from this +// function, so it could only ever convert one wrong answer into another. The native eight measured +// identically with it and without it, which is consistent with the gate being inert on that path AND with +// the path never having been this function's subject at all; the later receipt established the second. Its +// deletion is therefore a correctness repair on this function's own contract, claiming nothing about the +// eight. +// +// A NAMED FN'S PARAMETER AS THE HEAD IS THE SAME PATH-KEYED REFERENCE IT IS ANYWHERE ELSE. A ParameterFrame +// answers through resolve_frame_bound_reference, the one route that mints parameter_reference_node, so infer +// grounds the receiver through the resolved declarations exactly as it grounds a bare use of that parameter +// (v2.compiler.infer infer_parameter_reference_facts). A bare atom here was typed only by the scope search +// that main deleted, so it would leave every such receiver underived. A Lexical or type-parameter head stays +// the canonical atom: minting a lexical reference here would need its binding recorded, and this Outcome +// route carries no lexical entries, so it would refuse as unrecorded at infer. DECLARED FRONTIER, TRIGGER +// NAMING THE CAPABILITY: the projection route carries LexicalBindingEntry beside its node, SUFFICIENT FOR a +// let, match-arm or lambda binder to be a typed projection receiver. +fn resolve_projection_base(ctx: ResolveContext, head_segment: Node) -> Optional> { + match head_segment.kind { + TypeNode { connective: Atom { identity: name } } => + match lookup_chain(s: ctx.scope, name: name) { + Rejected { diagnostics: _ } => optional_absent() + Accepted { value: found, diagnostics: _ } => + match found { + BoundInFrame { canonical: canonical, kind: ParameterFrame { arrow_path: arrow_path } } => + optional_present(value: match resolve_frame_bound_reference( + ctx: ctx, + n: head_segment, + canonical: canonical, + kind: ParameterFrame { arrow_path: arrow_path }, + pending: None + ) { + Accepted { value: atom, diagnostics: d } => Accepted { value: atom.node, diagnostics: d } + Rejected { diagnostics: r } => Rejected { diagnostics: r } + }) + BoundInFrame { canonical: canonical, kind: _ } => + optional_present(value: Accepted { + value: canonical_atom(identity: canonical, occurrence_id: head_segment.occurrence_id), + diagnostics: None + }) + BoundAtRoot { canonical: canonical } => + optional_present(value: Accepted { + value: canonical_atom(identity: canonical, occurrence_id: head_segment.occurrence_id), + diagnostics: None + }) + ScopeUnbound => optional_absent() + } + } + TypeNode { connective: _ } => optional_absent() + ComputationNode { behavior: _ } => optional_absent() + } +} + +// THE CHAIN WINS, WHICH IS SECTION 13'S RULE AND NOT A NEW ONE. A chain's FIRST segment resolves on +// the ancestor chain and the rest projects, so a bound head is answered by the binder the author wrote +// and the absolute candidates are not consulted at all. +// +// THE AMBIGUOUS ARM THAT STOOD HERE WAS AN INTERIM GUARD WHOSE REASON IS GONE. qualified_head_bound_- +// on_chain's own header said what it was: "the half of unique-on-chain that can be honest BEFORE THE +// PROJECTION EXISTS", refusing AmbiguousQualifiedHeadShadowsAbsolute precisely because this arm could +// not project and therefore must not let the absolute read silently win. The projection now exists +// (resolve_bound_head_projection), so the guard DISSOLVES rather than weakens -- DESIGN section 4b(4), +// which also says the evidence does not retire: its discriminating control stays enrolled, flipped to +// assert that a bound head answers with its binder's projection. +// +// SO THIS IS NOT A REFUSAL BEING RELAXED. The state the refusal forbade -- the absolute read winning +// over a binder the source spelled -- is still forbidden; it is now answered correctly instead of +// refused, which is the climb the interim arm was waiting for. fn try_resolve_qualified_name_node(ctx: ResolveContext, n: Node) -> Optional> { match qualified_name_from_node(root: n) { Rejected { diagnostics: _ } => optional_absent() Accepted { value: path, diagnostics: pending } => if length(xs: path) > 1 { - match qualified_head_bound_on_chain(ctx: ctx, path: path) { + match qualified_head_scope_binding(ctx: ctx, path: path) { Rejected { diagnostics: r } => optional_present(value: Rejected { diagnostics: rejected_with_pending(pending: pending, rejected: r) }) - Accepted { value: head_bound, diagnostics: chain_pending } => - match symbol_index_absolute_candidates(index: ctx.namespace.symbol_index, qualified_path: path) { - Empty => + Accepted { value: head_binding, diagnostics: chain_pending } => optional_present( - value: Rejected { - diagnostics: rejected_with_pending( - pending: diagnostics_merge(outer: pending, inner: chain_pending), - rejected: diagnostics_singleton(d: unbound_symbol_diagnostic(n: n)) - ) - } + value: resolve_dotted_path_reading( + ctx: ctx, + n: n, + path: path, + head_binding: head_binding, + pending: diagnostics_merge(outer: pending, inner: chain_pending) + ) ) - Cons { head: candidate, tail: rest } => - match rest { - Empty => - if head_bound { - optional_present( - value: Rejected { - diagnostics: rejected_with_pending( - pending: diagnostics_merge(outer: pending, inner: chain_pending), - rejected: ambiguous_symbol_diagnostics( - n: n, - class: AmbiguousQualifiedHeadShadowsAbsolute { path: path } - ) - ) - } - ) - } else { - optional_present( - value: resolve_atom_bound( - ctx: ctx, - n: n, - path: candidate.path, - pending: diagnostics_merge(outer: pending, inner: chain_pending) - ) - ) - } - Cons { head: _, tail: _ } => - optional_present( - value: Rejected { - diagnostics: rejected_with_pending( - pending: diagnostics_merge(outer: pending, inner: chain_pending), - rejected: ambiguous_symbol_diagnostics( - n: n, - class: AmbiguousAtBindingPosition { - candidates: Cons { head: candidate, tail: rest } - } - ) - ) - } - ) - } - } } } else { optional_absent() @@ -1167,6 +1523,114 @@ fn try_resolve_qualified_name_node(ctx: ResolveContext, n: Node) -> Optional Outcome { + match head_binding { + BoundInFrame { canonical: _, kind: _ } => resolve_bound_head_projection(ctx: ctx, n: n, pending: pending) + BoundAtRoot { canonical: _ } => resolve_declared_path_reading(ctx: ctx, n: n, path: path, pending: pending) + ScopeUnbound => resolve_declared_path_reading(ctx: ctx, n: n, path: path, pending: pending) + } +} + +// A DECLARATION PATH THAT NAMES NOTHING REFUSES, AND MAY NOT WIDEN TO THE PROJECTION READING. This arm +// held a fallback -- no candidate for the path, so re-read it as a field projection off the root binding -- +// and that fallback is the absorbing arm DESIGN section 5 forbids, written into the very repair that was +// meant to remove a misreading. +// +// WHY A FALLBACK IS FORBIDDEN HERE, STATED AS THE RULE AND NOT AS AN INCIDENT. When the declaration +// reading cannot be established, the tempting arm substitutes the other reading, so nothing is ever +// reported as unresolvable. That is DESIGN section 5's absorbing fallback: the precise answer was +// unavailable and the arm widened to a superset reading, which both fabricates a derivation the source does +// not support and destroys the signal that the declaration evidence was missing. An earlier revision of +// this change shipped exactly that fallback and then attributed a native refusal to it; the attribution was +// falsified by measurement, and the fallback was wrong independently of whether anything consumed it. +// +// SO THE ARM REFUSES, and BoundAtRoot and ScopeUnbound reach the same reading: a path of two or more +// segments whose head is not a LEXICAL binder is a declaration name, and if the corpus declares no such +// name that is an unbound symbol with its own locus. Only BoundInFrame projects. The single-segment +// root-bound annotation that made deleting BoundAtRoot wrong earlier never arrives here -- the caller's +// length > 1 guard keeps it out -- so this costs that form nothing. +fn resolve_declared_path_reading( + ctx: ResolveContext, + n: Node, + path: QualifiedName, + pending: Diagnostics +) -> Outcome { + match symbol_index_absolute_candidates(index: ctx.namespace.symbol_index, qualified_path: path) { + Empty => + Rejected { + diagnostics: rejected_with_pending( + pending: pending, + rejected: diagnostics_singleton(d: unbound_symbol_diagnostic(n: n)) + ) + } + Cons { head: candidate, tail: rest } => + resolve_declared_path_candidates(ctx: ctx, n: n, candidate: candidate, rest: rest, pending: pending) + } +} + +// ONE CANDIDATE IS THE ANSWER AND SEVERAL ARE AMBIGUOUS. Factored out because both arms above reach it and +// a second copy of an ambiguity refusal is the duplicated authority DESIGN section 2 prices -- the two arms +// differ in what they do with an EMPTY candidate list, not in how they read a non-empty one. +fn resolve_declared_path_candidates( + ctx: ResolveContext, + n: Node, + candidate: LexicalBindingCandidate, + rest: FreeMonoid, + pending: Diagnostics +) -> Outcome { + match rest { + Empty => resolve_atom_bound(ctx: ctx, n: n, path: candidate.path, pending: pending) + Cons { head: _, tail: _ } => + Rejected { + diagnostics: rejected_with_pending( + pending: pending, + rejected: ambiguous_symbol_diagnostics( + n: n, + class: AmbiguousAtBindingPosition { candidates: Cons { head: candidate, tail: rest } } + ) + ) + } + } +} + fn root_binding_origin(namespace: Namespace, name: Symbol) -> QualifiedName { match map_lookup(m: namespace.imported_origins, key: name) { Present { value: origin } => origin @@ -1679,10 +2143,17 @@ fn resolve_walk_outcome(w: ResolveNodeWalk) -> Outcome { } // The stage exit: the same projection, with the index resolution consulted minted beside the root. -fn resolved_tree_outcome(w: ResolveNodeWalk, symbol_index: SymbolIndex) -> Outcome { +fn resolved_tree_outcome( + w: ResolveNodeWalk, + symbol_index: SymbolIndex, + closure_declarations: SymbolIndex +) -> Outcome { match w { ResolveWalkAccepted { value: v, diagnostics: d, lexical: l } => - Accepted { value: resolved_tree_of(root: v, symbol_index: symbol_index, lexical: l), diagnostics: d } + Accepted { + value: resolved_tree_of(root: v, symbol_index: symbol_index, closure_declarations: closure_declarations, lexical: l), + diagnostics: d + } ResolveWalkRefused { first: f, rest: _, observation: _ } => Rejected { diagnostics: f } } } @@ -2612,24 +3083,31 @@ fn resolve_match_arm_walk(ctx: ResolveContext, arm: Node) -> ResolveNodeWalk { match find_core_child(root: arm, marker: MatchArmPatternEdge) { Rejected { diagnostics: _ } => resolve_children_homogeneous_scope(ctx: ctx, n: arm) Accepted { value: pat, diagnostics: _ } => - let arm_ctx = resolve_ctx_with_scope( - ctx: ctx, - scope: lexical_frame(fb: resolve_pattern_binders(ctx: ctx, pat: pat, acc: frame_binders_empty()), outer: ctx.scope) - ) - child_walk_node(n: arm, w: fold(arm.children, init: child_walk_init(), f: fn(acc, e) { - child_walk_step( - w: acc, - e: e, - r: if edge_is_core(e: e, marker: MatchArmPatternEdge) { - resolve_pattern_node_walk(ctx: arm_ctx, pat: e.target) - } else { - resolve_node_walk(ctx: arm_ctx, n: e.target) - } - ) - })) + match gate_value_binders(names: pattern_binder_name_list(ctx: ctx, pat: pat), outer: ctx.scope, at: pat) { + BinderGateRefused { diagnostic: d } => resolve_walk_refused_one(chain: diagnostics_singleton(d: d)) + BinderGateAdmitted => resolve_match_arm_admitted(ctx: ctx, arm: arm, pat: pat) + } } } +fn resolve_match_arm_admitted(ctx: ResolveContext, arm: Node, pat: Node) -> ResolveNodeWalk { + let arm_ctx = resolve_ctx_with_scope( + ctx: ctx, + scope: lexical_frame(fb: resolve_pattern_binders(ctx: ctx, pat: pat, acc: frame_binders_empty()), outer: ctx.scope) + ) + child_walk_node(n: arm, w: fold(arm.children, init: child_walk_init(), f: fn(acc, e) { + child_walk_step( + w: acc, + e: e, + r: if edge_is_core(e: e, marker: MatchArmPatternEdge) { + resolve_pattern_node_walk(ctx: arm_ctx, pat: e.target) + } else { + resolve_node_walk(ctx: arm_ctx, n: e.target) + } + ) + })) +} + // children[0] is the scrutinee (outer scope); every later child is an arm. The ordinal advances on // every child, refused or not, so a refused scrutinee never promotes the first arm into its place. fn resolve_match_node_walk(ctx: ResolveContext, n: Node) -> ResolveNodeWalk { @@ -2678,10 +3156,10 @@ fn resolve_node_walk_entered( resolve_bind_node(ctx: ctx, n: n) ComputationNode { behavior: Match } => resolve_match_node_walk(ctx: ctx, n: n) - ComputationNode { behavior: Transform } => - resolve_transform_children(ctx: ctx, n: n) ComputationNode { behavior: Loop } => resolve_loop_node(ctx: ctx, n: n) + ComputationNode { behavior: Transform } => + resolve_transform_children(ctx: ctx, n: n) TypeNode { connective: Conj } => if resolve_is_where_refinement_clause(n: n) { resolve_where_refinement_clause(ctx: ctx, n: n) @@ -2719,6 +3197,21 @@ fn resolve_node_walk_entered( // it names the enclosing Bind's binder, which a desugaring wrote as a synthetic atom. It is checked // against that frame and kept as the binder atom, exactly as the Bind's own binder is // (resolve_bind_binder_target); minting it as a lexical reference would read a binder as a use. +// THIS LANE'S CARRIER FRAME IS DISSOLVED, AND THE REASON BELONGS HERE. The pre-#12550 fold seam built a +// BARE Loop with no enclosing Bind, so the carrier was bound by nothing and the step body's use of it +// reached the bare-name census; opening a frame at the Loop repaired that, and the native eight moved off +// their `found` refusal because of it. #12550 then replaced the seam with +// Bind { carrier := init, Loop { step, domain, carrier, bound, realized } }, which binds the carrier where +// the role model always said it lived -- the annotation above states it: the carrier resolves against the +// ENCLOSING Bind's frame. +// +// The frame then became redundant and harmful at once: the Loop re-admitted a name the Bind above it had +// already bound, and this lane's own value-binder admission refused it as hiding a visible binding. That is +// what the native eight reported at a synthetic occurrence, and what reds five of #12550's own claims. +// So the repair was correct for a structure that no longer exists and is DELETED rather than guarded -- a +// guard would have been this walk encoding a fact about which producer built the Loop (DESIGN section 6b), +// and the dissolution-on-climb rule asks for the obsolete production handling to go while the evidence +// stays (section 4b(4)). fn resolve_loop_node(ctx: ResolveContext, n: Node) -> ResolveNodeWalk { child_walk_node(n: n, w: fold(n.children, init: child_walk_init(), f: fn(acc, e) { match loop_edge_role(e: e) { @@ -2780,7 +3273,7 @@ fn realized_head_lookup(ctx: ResolveContext, identity: Symbol) -> Outcome Rejected { diagnostics: r } Accepted { value: found, diagnostics: pending } => match found { - BoundInFrame { canonical: _ } => Accepted { value: RealizedHeadBoundLocally, diagnostics: pending } + BoundInFrame { canonical: _, kind: _ } => Accepted { value: RealizedHeadBoundLocally, diagnostics: pending } BoundAtRoot { canonical: _ } => Accepted { value: RealizedHeadBound { @@ -3151,6 +3644,13 @@ fn resolve_node_consumes_expectation(n: Node) -> Bool { // is containment: its body, codomain and domain walk NotDeclaring, so an Arrow met there (a lambda) // opens a LexicalFrame. fn resolve_arrow_node_in(outer_ctx: ResolveContext, ctx: ResolveContext, n: Node) -> ResolveNodeWalk { + match gate_value_binders(names: arrow_domain_binder_name_list(n: n), outer: ctx.scope, at: n) { + BinderGateRefused { diagnostic: d } => resolve_walk_refused_one(chain: diagnostics_singleton(d: d)) + BinderGateAdmitted => resolve_arrow_node_admitted(outer_ctx: outer_ctx, ctx: ctx, n: n) + } +} + +fn resolve_arrow_node_admitted(outer_ctx: ResolveContext, ctx: ResolveContext, n: Node) -> ResolveNodeWalk { let scope_type = resolve_type_param_frame(n: n, outer: ctx.scope) let value_params = arrow_domain_frame_binders(n: n) let frame_kind = match ctx.declaring { @@ -3392,12 +3892,16 @@ fn resolve_bind_node( n: Node ) -> ResolveNodeWalk { if positional_child_count(children: n.children) == 3 { - child_walk_node(n: n, w: resolve_bind_edges( - ctx: ctx, - edges: n.children, - scope_outer: ctx.scope, - scope_inner: lexical_frame(fb: bind_frame_binders(n: n), outer: ctx.scope) - )) + match gate_value_binders(names: bind_binder_name_list(n: n), outer: ctx.scope, at: n) { + BinderGateRefused { diagnostic: d } => resolve_walk_refused_one(chain: diagnostics_singleton(d: d)) + BinderGateAdmitted => + child_walk_node(n: n, w: resolve_bind_edges( + ctx: ctx, + edges: n.children, + scope_outer: ctx.scope, + scope_inner: lexical_frame(fb: bind_frame_binders(n: n), outer: ctx.scope) + )) + } } else { resolve_walk_refused_one(chain: diagnostics_singleton(d: malformed_tree_diagnostic(n: n))) } @@ -3437,7 +3941,8 @@ fn resolve_with_namespace_policy( lm: lm, policy: policy ), - symbol_index: namespace.symbol_index + symbol_index: namespace.symbol_index, + closure_declarations: empty_symbol_index() ) } diff --git a/src/v2/compiler/04_infer.dag b/src/v2/compiler/04_infer.dag index 2562e8f07c1..9909a91c23d 100644 --- a/src/v2/compiler/04_infer.dag +++ b/src/v2/compiler/04_infer.dag @@ -1,5 +1,6 @@ module v2.compiler.infer + import std.occurrence_identity { OccurrenceSynthetic } import std.kind { Kind, RosterKindIndex, TypeDenotationKind, kind_denoted_by_node, kind_node, roster_kind_index, roster_kind_index_lookup } import v2.std.algebra { any, Empty, TailAbsent, TailFound, length, list_map, list_tail, zip_map } @@ -109,7 +110,8 @@ import v2.std.inhabitance { position_let_annotation, position_direct_call_argument, undecidable_argument_type_not_derived, - undecidable_formal_unresolved + undecidable_formal_unresolved, + undecidable_where_predicate_subject_unmodelled } import v2.std.diagnostic { Diagnostic, @@ -170,7 +172,16 @@ import v2.std.node { } import v2.std.type_binder { GenericTypeDecl, OpaqueTypeDecl, PlainTypeDecl, TypeAlias, cast_target_optional, declaration_binder_edge, edge_is_type_params, type_annotation_optional, type_decl_view, type_param_names } import v2.std.coercion { coercion_cast_crossing } -import v2.std.node_query { node_positional_child_targets, find_core_child } +import v2.std.node_query { + FieldProjection, + construct_tag_path_optional, + declared_field_named, + field_projection_optional, + find_core_child, + find_named_child, + node_positional_child_targets, + pattern_wildcard_name +} import v2.std.witness { Holds, StructuralPropertyWitness, Violates, Witness, witness_from_optional } type AlgebraRef { @@ -508,7 +519,8 @@ fn inferred_facts_construction( Accepted { value: InferredFacts { grounding: DerivedGrounding { grounding: grounding }, - descent: descent + descent: descent, + denotation: optional_absent() }, diagnostics: None } @@ -533,7 +545,8 @@ fn inferred_facts_not_derived( Accepted { value: InferredFacts { grounding: GroundingNotDerived { node: node }, - descent: descent + descent: descent, + denotation: optional_absent() }, diagnostics: Some { diagnostics: diagnostics_singleton( @@ -731,27 +744,45 @@ fn infer_formation_child_evidence_edges( // Conj or Arrow is product introduction, and a Disj of Named alternatives is SUM FORMATION -- the same // kind-preserving construction over its alternatives' evidence. Forming a sum type grants no // introduction or elimination: it says nothing about a value inhabiting one arm. +// THE DISPATCHER CARRIES THE RESOLVED CARRIER because its PlainTypeDecl arm reaches the product row, +// and that row answers a FIELD PROJECTION before it answers a product -- a projection needs the +// receiver's declaration, which only the index in ResolvedTree can supply. Main introduced this +// dispatcher while this lane threaded the carrier through the row beneath it; taking either side alone +// would have left the projection arm unreachable or the dispatcher unable to reach it. fn infer_formation_facts_from_entries( node: Node, entries: List, partials: List, + resolved: ResolvedTree, ) -> Outcome { match binder_node_parts(n: node) { Present { value: parts } => infer_binder_node_facts_from_entries(node: node, parts: parts, entries: entries) - Absent => infer_type_decl_formation_facts_from_entries(node: node, entries: entries, partials: partials) + Absent => infer_type_decl_formation_facts_from_entries(node: node, entries: entries, partials: partials, resolved: resolved) } } +// THE CARRIER REACHES THE PRODUCT ROW THROUGH THIS DISPATCHER, which is a merge seam worth naming. main +// introduced this function while this lane widened infer_product_facts_from_entries to take the +// ResolvedTree its projection arm needs, so main's caller passed three arguments to a row that now requires +// four. Threading `resolved` here is the resolution: the dispatcher above already holds it, and every arm +// that does not need it ignores it, so no arm gains a dependency it does not use. fn infer_type_decl_formation_facts_from_entries( node: Node, entries: List, partials: List, + resolved: ResolvedTree, ) -> Outcome { match type_decl_view(target: node) { TypeAlias { binders: _, aliased: _ } => infer_declaration_wrapper_facts_from_entries(node: node, entries: entries, partials: partials) GenericTypeDecl { binders: _, member: _ } => infer_declaration_wrapper_facts_from_entries(node: node, entries: entries, partials: partials) OpaqueTypeDecl { binders: _ } => infer_opaque_declaration_facts(node: node, partials: partials) - PlainTypeDecl { member: _ } => infer_product_facts_from_entries(node: node, entries: entries, partials: partials) + PlainTypeDecl { member: _ } => + infer_product_facts_from_entries( + node: node, + entries: entries, + partials: partials, + resolved: resolved + ) } } @@ -859,7 +890,18 @@ fn infer_product_facts_from_entries( node: Node, entries: List, partials: List, + resolved: ResolvedTree, ) -> Outcome { + match field_projection_optional(n: node) { + Present { value: projection } => + infer_field_projection_facts( + node: node, + projection: projection, + entries: entries, + partials: partials, + resolved: resolved + ) + Absent => match infer_bounded_lattice_consumer_gate(consumer: node, partials: partials) { Rejected { diagnostics: r } => Rejected { diagnostics: r } Accepted { value: _, diagnostics: cd } => @@ -892,6 +934,7 @@ fn infer_product_facts_from_entries( } } } + } } // A PARAMETER OF A NAMED FN IS GROUNDED THROUGH THE INDEX, LIKE ANY PATH-KEYED REFERENCE. @@ -998,6 +1041,14 @@ fn infer_term_node_facts(n: Node, partials: List, kinds: List + match infer_established_value_type_optional(ret: n) { + Present { value: _ } => + inferred_facts_from_derived_type( + node: n, + derived_type: kind_node(kind: TypeDenotationKind), + descent: Holds { value: descent_proof } + ) + Absent => match infer_roster_member_declared_type(n: n) { Present { value: member_type } => inferred_facts_from_derived_type( @@ -1011,11 +1062,13 @@ fn infer_term_node_facts(n: Node, partials: List, kinds: List - inferred_facts_not_derived( - node: n, - descent: Holds { value: descent_proof } + infer_declaration_reference_facts( + n: n, + resolved: resolved, + descent_proof: descent_proof ) } } @@ -1024,6 +1077,339 @@ fn infer_term_node_facts(n: Node, partials: List, kinds: List, + partials: List, + resolved: ResolvedTree +) -> Outcome { + match infer_bounded_lattice_consumer_gate(consumer: node, partials: partials) { + Rejected { diagnostics: r } => Rejected { diagnostics: r } + Accepted { value: _, diagnostics: cd } => + match infer_descent_witness_for_node(n: node) { + Violates { diagnostic: d } => + Rejected { diagnostics: diagnostics_singleton(d: d) } + Holds { value: descent_proof } => + match infer_projection_receiver( + base: projection.base, + entries: entries, + resolved: resolved + ) { + ReceiverTypeUnderived => + bind_outcome_accepted( + od: cd, + inner: inferred_facts_not_derived( + node: node, + descent: Holds { value: descent_proof } + ) + ) + ReceiverTypeNotADeclaration => + outcome_rejected(infer_receiver_declares_no_fields_diagnostic(node: node)) + ReceiverDeclarationUnavailable { path: _ } => + outcome_rejected(infer_receiver_declaration_unavailable_diagnostic(node: node)) + ReceiverPayload { payload: payload } => + match declared_field_named(payload: payload, name: projection.field) { + Present { value: declared } => + bind_outcome_accepted( + od: cd, + inner: inferred_facts_from_derived_type( + node: node, + derived_type: declared.type_node, + descent: Holds { value: descent_proof } + ) + ) + Absent => + outcome_rejected(infer_field_not_declared_diagnostic(node: node)) + } + } + } + } +} + +fn infer_field_not_declared_diagnostic(node: Node) -> Diagnostic { + Diagnostic { + reason: ^infer_reason_field_not_declared_on_receiver, + at: node_locus(node: node), + correction: Unavailable { reason: UserInputBoundary } + } +} + +// TWO UNAVAILABILITIES THAT MUST NOT BE ONE ARM. "I could not establish the receiver's type" and "the +// receiver's type is established and declares no fields" are different facts with opposite dispositions, +// and collapsing them into a single Absent is the absorbing fallback DESIGN section 5 forbids: it +// converts a decided negative into "no evidence" and lets the projection pass at the frontier. +// +// MEASURED, NOT ARGUED. Collapsed, this broke the standing negative control +// v2.test.claim.namespace_xl0.cross_module_reference_resolution +// a_receiver_with_no_such_child_never_accepts: `Bool.v` has no child `v`, and because resolve now +// commits the projection shape for a bound head, `Bool`'s established type is not a record reference, +// the payload lookup answered Absent, and the projection was ACCEPTED at the frontier. A receiver with +// no such child accepting is exactly what that control exists to forbid. +// +// SO THE DECIDED CASES REFUSE AND ONLY MISSING EVIDENCE WAITS. A receiver whose type this stage has not +// derived is ReceiverTypeUnderived and stays at the frontier, because convicting a program whose +// receiver is typed by a route not yet reaching here would be a wrong answer in the other direction. +// +// TWO CASES WERE ONE ARM AND THEY ARE NOT ONE FACT. ReceiverNotARecord carried both "the type is +// established and is not a declaration reference" and "the type names a declaration this context cannot +// retrieve", and both emitted projection_receiver_declares_no_fields. The second is not a fact about the +// program at all: a lookup miss establishes that THIS index does not hold the declaration, which is a +// statement about the evidence available here. Asserting it as "declares no fields" convicts a correct +// program of a defect it does not have. +// +// MEASURED, with one variable moved and a green positive control beside the red: +// v2.test.claim.field_projection.field_projection_stages +// fps_a_same_module_record_projection_infers PASSES and +// fps_a_cross_module_record_projection_infers FAILS over byte-identical records projected off a plain +// parameter, differing only in whether the record is declared in the consuming module or an imported one. +// The cause is structural rather than incidental: v2.compiler.resolve resolved_declarations_of fills the +// index from ONE module root, so a declaration an imported provider owns is absent by construction and +// every cross-module field read was being reported as a program defect. +// +// WHY THE UNAVAILABLE ARM REFUSES RATHER THAN WAITING AT THE FRONTIER. Accepting it as not-derived is the +// shape that broke a_receiver_with_no_such_child_never_accepts, recorded above: a receiver with no such +// child reached the frontier and was ACCEPTED. A blocked dependency must therefore be a located refusal +// with its OWN reason -- fail-closed, and saying what is actually unknown -- and never an acceptance and +// never a claim about the receiver's fields. The arm carries the path so a reader can name the declaration +// that could not be retrieved. +type ProjectionReceiver + = ReceiverPayload { payload: Node } + | ReceiverTypeNotADeclaration + | ReceiverDeclarationUnavailable { path: QualifiedName } + | ReceiverTypeUnderived + +fn infer_projection_receiver( + base: Node, + entries: List, + resolved: ResolvedTree +) -> ProjectionReceiver { + match lookup_inferred_facts_in_entries(entries: entries, key: base) { + Absent => ReceiverTypeUnderived + Present { value: base_facts } => + match inferred_facts_resolved_type(facts: base_facts) { + Violates { diagnostic: _ } => ReceiverTypeUnderived + Holds { value: receiver_type } => + match declaration_reference_path_optional(node: receiver_type) { + Absent => ReceiverTypeNotADeclaration + Present { value: path } => + match symbol_index_lookup(index: resolved.resolved_declarations, qualified_path: path) { + Absent => ReceiverDeclarationUnavailable { path: path } + Present { value: payload } => ReceiverPayload { payload: payload } + } + } + } + } +} + +// THE REFUSAL THAT NAMES MISSING EVIDENCE RATHER THAN A PROGRAM DEFECT. Its correction is a boundary of +// this compiler's own carrier, not of the author's input: the program may be entirely correct and the +// declaration simply unreachable from the index this stage was handed. Keeping it distinct from +// declares_no_fields is what lets the capability repair be verified -- when inference consumes a +// closure-level resolved-declaration authority this reason stops firing, and a corpus that still emits it +// names exactly which declaration could not be retrieved. +// THE LOCUS IS THE PROJECTION SITE AND NOT THE MISSING DECLARATION, deliberately: the author's cursor +// belongs where the program reads the field, and the unretrievable path is carried on +// ReceiverDeclarationUnavailable for a reader that wants it. NoCorrectionReason is a closed set in a shared +// authority (v2.std.diagnostic) with no arm for a carrier gap, and widening it for this one reason would be +// a change to every consumer of that type for a fact already held on this stage's own carrier -- +// CorrectionNotModeled is the honest existing arm, since no correction IS modeled for an index that does not +// reach the declaration. +fn infer_receiver_declaration_unavailable_diagnostic(node: Node) -> Diagnostic { + Diagnostic { + reason: ^infer_reason_projection_receiver_declaration_unavailable, + at: node_locus(node: node), + correction: Unavailable { reason: CorrectionNotModeled } + } +} + +fn infer_receiver_declares_no_fields_diagnostic(node: Node) -> Diagnostic { + Diagnostic { + reason: ^infer_reason_projection_receiver_declares_no_fields, + at: node_locus(node: node), + correction: Unavailable { reason: UserInputBoundary } + } +} + +// A REFERENCE TO A CORPUS DECLARATION IS TYPED BY THAT DECLARATION, THROUGH THE INDEX RESOLUTION +// ITSELF USED. Resolution answers WHICH declaration a reference denotes and carries it as a +// declaring path; this arm answers WHAT TYPE that declaration establishes for this use. Those are +// two questions and they stay in two stages: nothing here re-resolves a name, and resolution mints +// no types. +// +// THE PATH, NEVER THE LEAF. The deleted scope walk (infer_parameter_scope_search, superseded by +// resolve's recorded lexical bindings, ResolvedTree.lexical_bindings) recorded what a leaf-name answer +// costs: `fn positive(x: Int)` beside `fn f(x: Pos)` grounded every `x` in `f` as `Int`, a type +// fabricated from an unrelated declaration. symbol_index_lookup is asked for the whole declaring +// path, and it is the GUARDED read: a path with more than one bound declaring answers Absent, so a +// contested binding cannot silently yield a type. +// +// A LOOKUP HIT IS NOT TYPE EVIDENCE, AND READABLE PARAMETER NAMES ARE NOT EITHER. The index is built +// from validated normalized roots, which does not establish that an indexed declaration carries usable +// type evidence. An earlier draft of this arm guarded with arrow_domain_binder_labels and claimed that +// an unsupported signature stayed ungrounded; that claim was FALSE OF THE GUARD. That reader takes +// only `children`, so it never establishes the declaration IS an Arrow, and it inspects no parameter +// type, no return type, no scope and no body -- "I can read the parameter names" is a different +// property from "this declaration establishes this callable type", and the comment asserted the second +// while the code checked the first. +// +// THE GUARD IS NOW THE ONE AN APPLICATION WILL ASK. A reference is grounded only if the declaration +// satisfies what v2.compiler.infer's own application path requires of a callee: infer_operator_arrow +// (the node IS an Arrow), a domain whose formals are all named (infer_formals_from_domain), and a +// DECLARED parameter order (arrow_declared_parameter_order -- Absent or Malformed both refuse, because +// the domain is sorted by label for identity so its stored sequence is not the declared one). Grounding +// a reference whose declaration cannot satisfy those would mint evidence no consumer can use. +// +// WHAT THIS STILL DOES NOT ESTABLISH, stated rather than implied: the parameter and return TYPE +// references inside that signature are not resolved in the declaration's scope here, and no body or +// return obligation is discharged. Those remain the existing inference contract's, and a reference +// consuming a declared signature does not recheck a body at every use. This arm's claim is the +// structural callable contract, nothing wider. +// THE CALLABLE EVIDENCE OF A DECLARATION, or its absence. Reuses the application path's readers so a +// reference cannot ground to a callee shape that path would refuse. +fn infer_declaration_callable_evidence(declared: Node) -> Optional { + match infer_operator_arrow(operator: declared) { + Absent => Absent + Present { value: arrow } => + match list_at_optional(xs: node_positional_child_targets(node: arrow), index: 0) { + Absent => Absent + Present { value: domain } => + match infer_formals_from_domain(domain: domain) { + Absent => Absent + Present { value: _formals } => + match arrow_declared_parameter_order(arrow: arrow) { + ArrowParameterOrderAbsent => Absent + ArrowParameterOrderMalformed => Absent + ArrowParameterOrderDeclared { labels: _ } => Present { value: arrow } + } + } + } + } +} + +// A DECLARATION WHOSE CALLABLE EVIDENCE THIS STAGE CANNOT ADMIT IS THE FRONTIER, NOT A REFUSAL, and +// that is the SAME arm this function already takes when the declaration carries no callable evidence at +// all -- not a new leniency. canonical_grounding_from_derived_type holds every grounding the compiler +// mints to a self-consistency wall (canonical_grounding_admits_infer_facts: the node and its evidence +// well_formed, the two constraint properties, closedness evidence identical to the node). A declaration +// read from the RESOLVED root carries whatever resolve bound into its signature, and a return that is a +// generic instantiation -- `Outcome` -- is not a shape that wall admits. +// +// WHY ROUTING IT TO THE FRONTIER IS THE CORRECT ARM AND NOT A WIDENING. The reference is a USE; the +// question this function answers is "what callable contract does this use denote". When the answer is +// unavailable the honest result is that the use is underived, which infer already carries as the counted +// advisory infer_grounding_not_derived and every DEMANDING consumer -- eval, translate, coercion -- still +// refuses at its own gate. Letting the construction's refusal escape instead made a single-module named +// call whose callee returns a generic instance REJECT the whole module, with the incoherence reported +// against the declaration rather than against anything the program did wrong: a stage refusing a program +// for a route it does not reach. That is the conviction DESIGN section 5 forbids in the other direction, +// and it is a regression from the accepted-at-frontier behaviour, not a wall. +// +// THE RESIDUE IS STATED RATHER THAN COVERED: a generic-returning callee's application stays on the +// frontier, so its result type is not derived and a match on it is not checked against the coproduct's +// variants. The trigger is a canonical grounding for an instantiated generic type, which is the typing +// model's fact and not this reader's. +fn infer_reference_facts_or_frontier( + o: Outcome, + n: Node, + descent_proof: TerminationProof, +) -> Outcome { + match o { + Accepted { value: facts, diagnostics: d } => Accepted { value: facts, diagnostics: d } + Rejected { diagnostics: _ } => + inferred_facts_not_derived(node: n, descent: Holds { value: descent_proof }) + } +} + +fn infer_declaration_reference_facts( + n: Node, + resolved: ResolvedTree, + descent_proof: TerminationProof +) -> Outcome { + match declaration_reference_path_optional(node: n) { + Absent => + inferred_facts_not_derived(node: n, descent: Holds { value: descent_proof }) + Present { value: path } => + match symbol_index_lookup(index: resolved.resolved_declarations, qualified_path: path) { + Absent => + inferred_facts_not_derived(node: n, descent: Holds { value: descent_proof }) + Present { value: declared } => + match infer_declaration_callable_evidence(declared: declared) { + Absent => + inferred_facts_not_derived(node: n, descent: Holds { value: descent_proof }) + Present { value: callable } => + infer_reference_facts_or_frontier( + o: infer_facts_denoting( + o: inferred_facts_from_derived_type( + node: n, + derived_type: callable, + descent: Holds { value: descent_proof } + ), + declaration: declared + ), + n: n, + descent_proof: descent_proof + ) + } + } + } +} + +// THE DENOTATION IS RECORDED HERE AND NOWHERE ELSE, because here is where the GUARDED reader answered. +// eval cannot ask symbol_index_lookup -- it holds no index -- so without this the only routes open to +// it are a weaker second resolution authority over the tree (which would accept references the guard +// refuses, DESIGN section 3) or reading the declaration's body out of the callable TYPE evidence +// (which conflates two facts). Recording the declaration the lookup returned keeps one authority for +// "which declaration does this reference name" and leaves eval a consumer of it. +// +// ONLY A GROUNDED REFERENCE CARRIES ONE. An underived arm keeps its Absent denotation, so a consumer +// cannot reach an executable body for a reference whose contract was refused. +fn infer_facts_denoting(o: Outcome, declaration: Node) -> Outcome { + bind_outcome( + o: o, + f: fn(facts) { + outcome_accepted( + value: InferredFacts { + grounding: facts.grounding, + descent: facts.descent, + denotation: optional_present(value: declaration) + } + ) + } + ) +} + fn lookup_inferred_facts_in_entries( entries: List, key: Node, @@ -1178,13 +1564,43 @@ fn infer_binding_value_type_witness(binding: Symbol, at: Node) -> Witness // (positional child 1). Read from the declaration, never from the Arrow's composed evidence, which // also carries the body. Absent when the return is not a binding this language's join denotes, and // then nothing is derived from it. +// A RETURN EXPRESSION THAT IS ALREADY AN ESTABLISHED TYPE IS CONSUMED, NOT DENOTED AGAIN. This reader +// sent every return Atom's identity to dag_binding_denotation, which is a BINDING-to-type operation. +// Int survived that because its canonical type constructor retains the historical spelling +// ^dag_binding_type_int, so its binding and type identities coincide and a second denotation is a +// no-op. Bool does not: it arrives as the DENOTED node (v2.std.logic bool_node, ^bool_node_symbol), +// the binding lookup answered Absent, and both consumers of this reader lost the return -- the +// application's result typing dropped to the frontier, and the body-versus-declared-return check +// skipped its comparison. So Int masked the reader's assumption and Bool exposed it. +// +// THE RECOGNITION IS BY AUTHORITY, NOT BY SPELLING. The established case is compared against +// v2.std.logic's own bool_node() through the existing structural equality, rather than teaching a +// second meaning for ^bool_node_symbol here or widening dag_binding_denotation to accept a denoted +// symbol -- that lookup stays strictly binding-to-type. An arbitrary Atom is not a resolved value type +// and still answers Absent. +// +// ORDERED DENOTATION-FIRST so the Int path is byte-identical: only a return the binding lookup cannot +// denote reaches the established-type question. ONE reader, so introduction and elimination cannot +// disagree about the same signature. +fn infer_established_value_type_optional(ret: Node) -> Optional { + if infer_type_equal_ignoring_provenance(a: ret, b: bool_node()) { + optional_present(value: bool_node()) + } else { + optional_absent() + } +} + fn infer_arrow_declared_return_type(arrow: Node) -> Optional { match list_at_optional(xs: node_positional_child_targets(node: arrow), index: 1) { Absent => Absent Present { value: ret } => match infer_atom_binding_sym(node: ret) { - Absent => Absent - Present { value: binding } => dag_binding_denotation(sym: binding) + Absent => infer_established_value_type_optional(ret: ret) + Present { value: binding } => + match dag_binding_denotation(sym: binding) { + Present { value: denoted } => Present { value: denoted } + Absent => infer_established_value_type_optional(ret: ret) + } } } } @@ -1196,6 +1612,32 @@ fn infer_atom_binding_sym(node: Node) -> Optional { } } +fn infer_conj_edge_named_type_for_binding(edge: Edge, binding: Symbol) -> Optional { + match declared_field_from_edge(e: edge) { + Present { value: binder } => + if binder.name == binding { + optional_present(value: binder.type_node) + } else { + optional_absent() + } + Absent => optional_absent() + } +} + +fn infer_conj_named_type_for_binding(domain: Node, binding: Symbol) -> Optional { + match domain.kind { + TypeNode { connective: Conj } => + fold(domain.children, init: Absent, f: fn(acc, e) { + match acc { + Present { value: _ } => acc + Absent => infer_conj_edge_named_type_for_binding(edge: e, binding: binding) + } + }) + TypeNode { connective: _ } => Absent + ComputationNode { behavior: _ } => Absent + } +} + // A REFINEMENT DECLARATION, READ FROM A TYPE REFERENCE THROUGH THE AUTHORITY RESOLUTION USED. A // resolved reference carries its declaration's qualified path (v2.std.qualified_name // declaration_reference_path_optional); the declaration is what v2.std.symbol_index @@ -1212,7 +1654,8 @@ fn infer_atom_binding_sym(node: Node) -> Optional { // coercion_cast_crossing (an explicit cast's declared-carrier widening) and, through // infer_judge_declared_position, to v2.std.inhabitance declared_type_inhabitance (the same one-step // widening at every declared position: argument, return, field and let). .where_clause is a DECLARED -// FRONTIER, not yet consumed: its consumer is the literal-into-refinement cast arm of work item +// FRONTIER, not yet consumed (and the predicate CALLS inside it are walked unjudged and counted under the +// same frontier: infer_where_predicate_set_edge): its consumer is the literal-into-refinement cast arm of work item // adhoc-032c89dc-138 (deep-bee-18), which projects it into resolve's where-predicate bindings. That arm // lands with the next-rung trigger of gunbc.recurring_failure_mode as_cast_has_no_lowered_form // (v2.std.coercion deciding a refinement's predicate on the cast operand); it must refuse on Absent @@ -1723,6 +2166,554 @@ fn infer_match_bool( } } +// A MATCH OVER A DECLARED COPRODUCT. Which arm family a match takes is decided by its PATTERNS, not +// by whether its scrutinee's type happened to derive: an arm pattern that is a Bool or Int literal is +// the literal family (infer_match_bool, unchanged), and a match with a constructor pattern is +// eliminating a declared coproduct. Deciding by the scrutinee's type instead would route a coproduct match whose scrutinee +// is not yet typed into the literal family, which refuses it as infer_match_scrutinee_not_bool -- a +// verdict about the wrong question. +fn infer_match_arm_pattern_is_literal(arm: Node) -> Bool { + match find_core_child(root: arm, marker: MatchArmPatternEdge) { + Rejected { diagnostics: _ } => false + Accepted { value: pat, diagnostics: _ } => + match dag_canonical_literal_from_node(node: pat) { + Accepted { value: _, diagnostics: _ } => true + Rejected { diagnostics: _ } => false + } + } +} + +// A match reaches the coproduct family only when some arm IS a constructor pattern. A match whose +// patterns are neither literals nor constructors -- bare atoms naming nothing, say -- keeps the literal +// family's refusal (infer_match_pattern_not_bool_literal): routing it here instead would meet an +// untyped scrutinee and accept it at the frontier, widening a refusal into an admission +// (v2.test.claim.manual.match_infer_fail_open_audit complement_body_real_infer_refuses_unsupported_pattern_holds). +fn infer_match_arm_pattern_is_constructor(arm: Node) -> Bool { + match find_core_child(root: arm, marker: MatchArmPatternEdge) { + Rejected { diagnostics: _ } => false + Accepted { value: pat, diagnostics: _ } => + match infer_coproduct_arm_pattern(pat: pat) { + ArmPatternVariant { tag: _, fields: _ } => true + ArmPatternWildcard => false + ArmPatternUnread => false + } + } +} + +fn infer_match( + node: Node, + partials: List, + entries: List, + resolved: ResolvedTree +) -> Outcome { + let positional_targets = node_positional_child_targets(node: node) + match list_tail(xs: positional_targets) { + TailAbsent => outcome_rejected(infer_match_shape_invalid_diagnostic(node: node)) + TailFound { tail: arms } => + if any(xs: arms, predicate: fn(arm) { infer_match_arm_pattern_is_literal(arm: arm) }) { + infer_match_bool(node: node, partials: partials, entries: entries, resolved: resolved) + } else if any(xs: arms, predicate: fn(arm) { infer_match_arm_pattern_is_constructor(arm: arm) }) { + infer_match_coproduct(node: node, partials: partials, entries: entries, resolved: resolved) + } else { + infer_match_bool(node: node, partials: partials, entries: entries, resolved: resolved) + } + } +} + +// THE COPRODUCT THE SCRUTINEE'S TYPE DENOTES, INSTANTIATED. `Outcome` is an +// Instantiation whose head is a declaration reference and whose remaining positional children are the +// type arguments; a bare reference is the same with no arguments. The head's declaring path is asked +// of the index through the guarded read (symbol_index_lookup), exactly as a field projection's +// receiver is (infer_projection_receiver), and the declaration's type parameters are the index's +// record of the binders the member cannot carry (symbol_index_declared_type_params_at). Each argument +// instantiates the parameter at its position, so a variant field declared `value: T` reads as the +// argument itself. A declared coproduct is a Disj; anything else -- a record, a kernel type, a path the +// index does not hold -- declares no variants to eliminate, and a count of arguments different from the +// count of parameters binds no instance at all. +type InferMatchCoproduct { + variants: Node + instances: List +} + +type InferMatchCoproductRead + = CoproductRead { coproduct: InferMatchCoproduct } + | CoproductNotDeclared + | CoproductArityMismatch + +type InferTypeHeadArgs { + head: Node + args: List +} + +fn infer_type_head_and_args(t: Node) -> InferTypeHeadArgs { + match t.kind { + TypeNode { connective: Instantiation } => + let targets = node_positional_child_targets(node: t) + match list_at_optional(xs: targets, index: 0) { + Absent => InferTypeHeadArgs { head: t, args: Empty } + Present { value: head } => + match list_tail(xs: targets) { + TailFound { tail: args } => InferTypeHeadArgs { head: head, args: args } + TailAbsent => InferTypeHeadArgs { head: head, args: Empty } + } + } + TypeNode { connective: _ } => InferTypeHeadArgs { head: t, args: Empty } + ComputationNode { behavior: _ } => InferTypeHeadArgs { head: t, args: Empty } + } +} + +fn infer_match_coproduct_of_type(scrutinee_type: Node, resolved: ResolvedTree) -> InferMatchCoproductRead { + let head_args = infer_type_head_and_args(t: scrutinee_type) + match declaration_reference_path_optional(node: head_args.head) { + Absent => CoproductNotDeclared + Present { value: path } => + match symbol_index_lookup(index: resolved.symbol_index, qualified_path: path) { + Absent => CoproductNotDeclared + Present { value: declared } => + match declared.kind { + TypeNode { connective: Disj } => + let params = symbol_index_declared_type_params_at(index: resolved.symbol_index, qualified_path: path) + if length(xs: params) != length(xs: head_args.args) { + CoproductArityMismatch + } else { + CoproductRead { + coproduct: InferMatchCoproduct { + variants: declared, + instances: zip_map(a: params, b: head_args.args, f: fn(p, arg) { + TypeVariableInstance { binder: p, instance: arg } + }) + } + } + } + TypeNode { connective: _ } => CoproductNotDeclared + ComputationNode { behavior: _ } => CoproductNotDeclared + } + } + } +} + +// A VARIANT'S PAYLOAD IS THE TARGET OF ITS TAG EDGE, NOT A DECLARED FIELD. A coproduct is a Disj whose +// Authored edges are labelled by variant tag and target the payload Conj directly; declared_field_from_edge +// answers only for an edge whose target is a binder node (v2.std.node_query binder_node_parts), so reading a +// variant through it answered Absent for every tag once fields became binder nodes, and every match refused +// as infer_match_pattern_variant_not_declared. The payload's FIELDS are declared fields and keep that reader. +fn infer_match_variant_payload(variants: Node, tag: Symbol) -> Optional { + fold(variants.children, init: Absent, f: fn(acc, e) { + match acc { + Present { value: _ } => acc + Absent => + match e.label { + Authored { name: variant } => if variant == tag { optional_present(value: e.target) } else { Absent } + StructuralLabel { label: _ } => Absent + Positional => Absent + } + } + }) +} + +fn infer_match_variant_tags(variants: Node) -> List { + fold(variants.children, init: Empty, f: fn(acc, e) { + match e.label { + Authored { name: tag } => list_snoc_item(xs: acc, item: tag) + StructuralLabel { label: _ } => acc + Positional => acc + } + }) +} + +// A VARIANT FIELD'S TYPE AT THIS INSTANTIATION, OR NOTHING. A field declared as one of the +// declaration's type parameters is that parameter's argument -- a type the USE SITE resolved. A field +// declared as a kernel type is that type's denotation, the same join infer_arrow_declared_return_type +// reads. Any other field type is a reference authored in the DECLARATION'S scope, which the index +// holds as written and this stage does not re-resolve (infer_declaration_reference_facts states the +// same boundary for signatures), so it is not derived here rather than minted as an unresolved atom a +// consumer would read as a type. +fn infer_match_field_type_instantiated(field_type: Node, instances: List) -> Optional { + match field_type.kind { + TypeNode { connective: Atom { identity: id } } => + if count(field_type.children) == 0 { + match type_variable_instance_lookup(instances: instances, binder: id) { + Present { value: instance } => optional_present(value: instance) + Absent => dag_binding_denotation(sym: id) + } + } else { + optional_absent() + } + TypeNode { connective: _ } => Absent + ComputationNode { behavior: _ } => Absent + } +} + +// AN ARM PATTERN, READ THROUGH THE CONSTRUCT ENCODING'S OWN AUTHORITY. A resolved constructor pattern is +// a Conj whose FIRST edge carries the fixed v2.std.node_query construct_tag_marker() and whose TARGET is +// the tag's declaration reference; the remaining edges are the fields it names. A nullary variant written +// bare is a declaration reference alone; `_` is the wildcard. Nested and literal field patterns are not +// read here: they narrow what an arm covers, and an arm this stage cannot account for refuses rather than +// being counted as covering its variant. +// +// THE TAG IS ASKED OF construct_tag_path_optional RATHER THAN READ OFF THE FIRST EDGE'S LABEL, and that +// is not a refactor -- it is the repair for reading the encoding through a second, positional scheme. +// This reader took the first edge's LABEL to BE the variant tag, which was true of the encoding before +// gunbc#12714 made a construct tag a declaration reference. After it, that label is always the marker, so +// every constructor arm looked up the MARKER as a variant name: the positives went red and +// mbt_a_variant_the_coproduct_does_not_declare_refuses kept passing FOR THE WRONG REASON -- it wants an +// undeclared variant to refuse, and every variant had become undeclared. +// +// That is the §3 lesson in miniature: the encoding has one reader, and a consumer that re-derives the tag +// from edge positions is a second authority that decays silently the moment the encoding moves. +type InferCoproductArmPattern + = ArmPatternVariant { tag: Symbol, fields: List } + | ArmPatternWildcard + | ArmPatternUnread + +fn infer_coproduct_arm_pattern(pat: Node) -> InferCoproductArmPattern { + match pat.kind { + TypeNode { connective: Atom { identity: id } } => + if id == pattern_wildcard_name() { ArmPatternWildcard } else { ArmPatternUnread } + TypeNode { connective: _ } => infer_coproduct_arm_pattern_constructed(pat: pat) + ComputationNode { behavior: _ } => infer_coproduct_arm_pattern_constructed(pat: pat) + } +} + +// A non-atom arm pattern: a bare variant reference or a constructed pattern with field binders. +fn infer_coproduct_arm_pattern_constructed(pat: Node) -> InferCoproductArmPattern { + match declaration_reference_path_optional(node: pat) { + Present { value: path } => + match qualified_name_last_segment(qn: path) { + Present { value: tag } => ArmPatternVariant { tag: tag, fields: Empty } + Absent => ArmPatternUnread + } + Absent => + match construct_tag_path_optional(n: pat) { + Absent => ArmPatternUnread + Present { value: path } => + match qualified_name_last_segment(qn: path) { + Absent => ArmPatternUnread + Present { value: tag } => + match list_tail(xs: pat.children) { + TailFound { tail: fields } => ArmPatternVariant { tag: tag, fields: fields } + TailAbsent => ArmPatternVariant { tag: tag, fields: Empty } + } + } + } + } +} + +// A FIELD BINDER ARRIVES AS RESOLVE LEAVES IT: v2.compiler.resolve resolve_pattern_node_walk walks a +// non-construct field target as an ordinary node, and the arm's Lexical frame answers the binder's own +// name, so the binder is a lexical reference node (v2.std.qualified_name lexical_reference_node) keyed by +// its own occurrence, not a bare atom. A bare atom is still read, for a pattern built without resolve. +fn infer_field_pattern_binds(target: Node) -> Optional { + match lexical_reference_label_optional(node: target) { + Present { value: label } => optional_present(value: label) + Absent => + match target.kind { + TypeNode { connective: Atom { identity: id } } => + if (count(target.children) == 0) && (id != pattern_wildcard_name()) { optional_present(value: id) } else { optional_absent() } + TypeNode { connective: _ } => Absent + ComputationNode { behavior: _ } => Absent + } + } +} + +fn infer_field_pattern_is_wildcard(target: Node) -> Bool { + match target.kind { + TypeNode { connective: Atom { identity: id } } => id == pattern_wildcard_name() + TypeNode { connective: _ } => false + ComputationNode { behavior: _ } => false + } +} + +fn infer_match_pattern_variant_not_declared_diagnostic(pattern: Node) -> Diagnostic { + Diagnostic { + reason: ^infer_match_pattern_variant_not_declared, + at: node_locus(node: pattern), + correction: Unavailable { reason: UserInputBoundary } + } +} + +fn infer_match_pattern_field_not_declared_diagnostic(pattern: Node) -> Diagnostic { + Diagnostic { + reason: ^infer_match_pattern_field_not_declared, + at: node_locus(node: pattern), + correction: Unavailable { reason: UserInputBoundary } + } +} + +fn infer_match_pattern_unread_diagnostic(pattern: Node) -> Diagnostic { + Diagnostic { + reason: ^infer_match_pattern_unread, + at: node_locus(node: pattern), + correction: Unavailable { reason: ExternalContractUnknown } + } +} + +fn infer_match_scrutinee_not_coproduct_diagnostic(node: Node) -> Diagnostic { + Diagnostic { + reason: ^infer_match_scrutinee_not_coproduct, + at: node_locus(node: node), + correction: Unavailable { reason: UserInputBoundary } + } +} + +fn infer_match_type_argument_arity_mismatch_diagnostic(node: Node) -> Diagnostic { + Diagnostic { + reason: ^infer_match_type_argument_arity_mismatch, + at: node_locus(node: node), + correction: Unavailable { reason: UserInputBoundary } + } +} + +// THE TWO FRONTIER ADVISORIES. Each rides the Accepted path as a located diagnostic, one per site, so +// a match left untyped is counted where it stands and never reads as a typed result: the facts it +// carries are GroundingNotDerived, which every consumer that demands a type refuses +// (infer_grounding_demanded_not_derived). +fn infer_match_scrutinee_type_underived_diagnostic(node: Node) -> Diagnostic { + Diagnostic { + reason: ^infer_match_scrutinee_type_underived, + at: node_locus(node: node), + correction: Unavailable { reason: ExternalContractUnknown } + } +} + +fn infer_match_arm_body_type_underived_diagnostic(body: Node) -> Diagnostic { + Diagnostic { + reason: ^infer_match_arm_body_type_underived, + at: node_locus(node: body), + correction: Unavailable { reason: ExternalContractUnknown } + } +} + +// ONE ARM, CHECKED AGAINST THE DECLARATION. The variant must be one the coproduct declares and every +// field the pattern names must be one that variant declares; each is a fact about the program, so each +// refuses. The arm's coverage is its variant's tag, or every variant for a wildcard. +type InferCoproductArmRow { + covers: InferCoproductArmCoverage + body: Node +} + +type InferCoproductArmCoverage + = CoversVariant { tag: Symbol } + | CoversAll + +fn infer_coproduct_arm_fields_check(pat: Node, payload: Node, fields: List) -> Outcome { + fold(fields, init: Accepted { value: true, diagnostics: None }, f: fn(acc, e) { + match acc { + Rejected { diagnostics: r } => Rejected { diagnostics: r } + Accepted { value: _, diagnostics: _ } => + match e.label { + Positional => outcome_rejected(infer_match_pattern_unread_diagnostic(pattern: pat)) + StructuralLabel { label: _ } => outcome_rejected(infer_match_pattern_unread_diagnostic(pattern: pat)) + Authored { name: field } => + match infer_conj_named_type_for_binding(domain: payload, binding: field) { + Absent => outcome_rejected(infer_match_pattern_field_not_declared_diagnostic(pattern: pat)) + Present { value: _ } => + match infer_field_pattern_binds(target: e.target) { + Present { value: _ } => acc + Absent => + if infer_field_pattern_is_wildcard(target: e.target) { + acc + } else { + outcome_rejected(infer_match_pattern_unread_diagnostic(pattern: e.target)) + } + } + } + } + } + }) +} + +fn infer_coproduct_arm_row(arm: Node, coproduct: InferMatchCoproduct) -> Outcome { + match find_core_child(root: arm, marker: MatchArmPatternEdge) { + Rejected { diagnostics: _ } => outcome_rejected(infer_match_shape_invalid_diagnostic(node: arm)) + Accepted { value: pat, diagnostics: _ } => + match find_core_child(root: arm, marker: MatchArmBodyEdge) { + Rejected { diagnostics: _ } => outcome_rejected(infer_match_shape_invalid_diagnostic(node: arm)) + Accepted { value: body, diagnostics: _ } => + match infer_coproduct_arm_pattern(pat: pat) { + ArmPatternUnread => outcome_rejected(infer_match_pattern_unread_diagnostic(pattern: pat)) + ArmPatternWildcard => + outcome_accepted(value: InferCoproductArmRow { covers: CoversAll, body: body }) + ArmPatternVariant { tag: tag, fields: fields } => + match infer_match_variant_payload(variants: coproduct.variants, tag: tag) { + Absent => outcome_rejected(infer_match_pattern_variant_not_declared_diagnostic(pattern: pat)) + Present { value: payload } => + bind_outcome( + o: infer_coproduct_arm_fields_check(pat: pat, payload: payload, fields: fields), + f: fn(_checked) { + outcome_accepted(value: InferCoproductArmRow { covers: CoversVariant { tag: tag }, body: body }) + } + ) + } + } + } + } +} + +fn infer_coproduct_rows_cover(rows: List, tag: Symbol) -> Bool { + any(xs: rows, predicate: fn(row) { + match row.covers { + CoversAll => true + CoversVariant { tag: t } => t == tag + } + }) +} + +// THE ARM BODIES' ONE TYPE. Two DERIVED body types that differ are a mismatch and refuse; a body whose +// type is not derived leaves the match at the frontier with one located advisory per such body, and +// the match's own facts are GroundingNotDerived rather than the type of the arms that did derive -- a +// match typed from a subset of its arms would be a claim about the arms nobody checked. +type InferArmBodyTypes { + derived: Optional + mismatch: Bool + underived: Diagnostics +} + +fn infer_coproduct_arm_body_types( + rows: List, + entries: List, + resolved: ResolvedTree +) -> InferArmBodyTypes { + fold(rows, init: InferArmBodyTypes { derived: Absent, mismatch: false, underived: None }, f: fn(acc, row) { + let body_type = match lookup_inferred_facts_in_entries(entries: entries, key: row.body) { + Absent => Absent + Present { value: facts } => + match infer_branch_operand_resolved_type_in_tree(node: row.body, facts: facts, resolved: resolved) { + Holds { value: t } => Present { value: t } + Violates { diagnostic: _ } => Absent + } + } + match body_type { + Absent => + InferArmBodyTypes { + derived: acc.derived, + mismatch: acc.mismatch, + underived: diagnostics_merge( + outer: acc.underived, + inner: Some { diagnostics: diagnostics_singleton(d: infer_match_arm_body_type_underived_diagnostic(body: row.body)) } + ) + } + Present { value: t } => + match acc.derived { + Absent => InferArmBodyTypes { derived: Present { value: t }, mismatch: acc.mismatch, underived: acc.underived } + Present { value: held } => + InferArmBodyTypes { + derived: acc.derived, + mismatch: acc.mismatch || !infer_type_equal_ignoring_provenance(a: held, b: t), + underived: acc.underived + } + } + } + }) +} + +fn infer_match_frontier(node: Node, cd: Diagnostics, advisories: Diagnostics) -> Outcome { + match infer_descent_witness_for_node(n: node) { + Violates { diagnostic: d } => Rejected { diagnostics: diagnostics_singleton(d: d) } + Holds { value: descent_proof } => + bind_outcome( + o: inferred_facts_not_derived(node: node, descent: Holds { value: descent_proof }), + f: fn(facts) { + Accepted { value: facts, diagnostics: diagnostics_merge(outer: cd, inner: advisories) } + } + ) + } +} + +fn infer_match_coproduct_rows( + node: Node, + arms: List, + coproduct: InferMatchCoproduct, + entries: List, + resolved: ResolvedTree, + cd: Diagnostics +) -> Outcome { + match fold(arms, init: Accepted { value: Empty, diagnostics: None }, f: fn(acc, arm) { + match acc { + Rejected { diagnostics: r } => Rejected { diagnostics: r } + Accepted { value: rows, diagnostics: d } => + match infer_coproduct_arm_row(arm: arm, coproduct: coproduct) { + Rejected { diagnostics: r } => Rejected { diagnostics: r } + Accepted { value: row, diagnostics: _ } => Accepted { value: list_snoc_item(xs: rows, item: row), diagnostics: d } + } + } + }) { + Rejected { diagnostics: r } => Rejected { diagnostics: r } + Accepted { value: rows, diagnostics: _ } => + if any(xs: infer_match_variant_tags(variants: coproduct.variants), predicate: fn(tag) { + !infer_coproduct_rows_cover(rows: rows, tag: tag) + }) { + outcome_rejected(infer_match_non_exhaustive_diagnostic(node: node)) + } else { + let bodies = infer_coproduct_arm_body_types(rows: rows, entries: entries, resolved: resolved) + if bodies.mismatch { + outcome_rejected(infer_match_arm_type_mismatch_diagnostic(node: node)) + } else { + match bodies.underived { + Some { diagnostics: _ } => infer_match_frontier(node: node, cd: cd, advisories: bodies.underived) + None => + match bodies.derived { + Absent => infer_match_frontier(node: node, cd: cd, advisories: None) + Present { value: t } => + match infer_descent_witness_for_node(n: node) { + Violates { diagnostic: d } => Rejected { diagnostics: diagnostics_singleton(d: d) } + Holds { value: descent_proof } => + bind_outcome( + o: inferred_facts_from_derived_type(node: node, derived_type: t, descent: Holds { value: descent_proof }), + f: fn(facts) { Accepted { value: facts, diagnostics: cd } } + ) + } + } + } + } + } + } +} + +fn infer_match_coproduct( + node: Node, + partials: List, + entries: List, + resolved: ResolvedTree +) -> Outcome { + let positional_targets = node_positional_child_targets(node: node) + if !all_edges_positional(children: node.children) { + outcome_rejected(infer_match_shape_invalid_diagnostic(node: node)) + } else { + match list_at_optional(xs: positional_targets, index: 0) { + Absent => outcome_rejected(infer_match_shape_invalid_diagnostic(node: node)) + Present { value: scrutinee_target } => + match list_tail(xs: positional_targets) { + TailAbsent => outcome_rejected(infer_match_non_exhaustive_diagnostic(node: node)) + TailFound { tail: arms } => + match infer_bounded_lattice_consumer_gate(consumer: node, partials: partials) { + Rejected { diagnostics: r } => Rejected { diagnostics: r } + Accepted { value: _, diagnostics: cd } => + match lookup_inferred_facts_in_entries(entries: entries, key: scrutinee_target) { + Absent => outcome_rejected(infer_facts_lookup_miss_diagnostic(key: scrutinee_target)) + Present { value: scrutinee_facts } => + match infer_branch_operand_resolved_type_in_tree(node: scrutinee_target, facts: scrutinee_facts, resolved: resolved) { + Violates { diagnostic: _ } => + infer_match_frontier( + node: node, + cd: cd, + advisories: Some { diagnostics: diagnostics_singleton(d: infer_match_scrutinee_type_underived_diagnostic(node: scrutinee_target)) } + ) + Holds { value: scrutinee_type } => + match infer_match_coproduct_of_type(scrutinee_type: scrutinee_type, resolved: resolved) { + CoproductNotDeclared => outcome_rejected(infer_match_scrutinee_not_coproduct_diagnostic(node: scrutinee_target)) + CoproductArityMismatch => outcome_rejected(infer_match_type_argument_arity_mismatch_diagnostic(node: scrutinee_target)) + CoproductRead { coproduct: coproduct } => + infer_match_coproduct_rows(node: node, arms: arms, coproduct: coproduct, entries: entries, resolved: resolved, cd: cd) + } + } + } + } + } + } + } +} + // FLAG C (body-lowering design, operator-signed): the first cut types carrier-invariant loops only // — every iteration-fold child must resolve to ONE carrier type τ, so the inferred tree states // 'body : τ → τ, repeated under the measure'. A refinement-shaped loop (carrier narrows across @@ -1927,10 +2918,36 @@ fn infer_formals_from_domain(domain: Node) -> Optional> } } -fn infer_application_callee_arrow(node: Node) -> Optional { +// THE CALLEE'S ARROW, FROM THE CALLEE'S OWN EVIDENCE WHEN THE NODE IS NOT ONE ITSELF. A resolved +// declaration reference stays a Conj however well typed it is, so infer_operator_arrow answers Absent +// for it and every consumer of this helper -- formals, type parameters, argument inhabitance, result +// typing -- fell through to the undecidable-accepted arm. Giving the reference callable facts does not +// make this path read them; this is where it reads them. The use keeps its own node and occurrence: +// only the TYPE is taken from its facts entry, never the declaration's body or identity. +fn infer_application_callee_use(node: Node) -> Optional { + list_at_optional(xs: node_positional_child_targets(node: node), index: 0) +} + +fn infer_application_callee_arrow_with_facts( + node: Node, + entries: List +) -> Optional { match list_at_optional(xs: node_positional_child_targets(node: node), index: 0) { Absent => Absent - Present { value: operator } => infer_operator_arrow(operator: operator) + Present { value: operator } => + match infer_operator_arrow(operator: operator) { + Present { value: arrow } => Present { value: arrow } + Absent => + match lookup_inferred_facts_in_entries(entries: entries, key: operator) { + Absent => Absent + Present { value: facts } => + match facts.grounding { + GroundingNotDerived { node: _ } => Absent + DerivedGrounding { grounding: g } => + infer_operator_arrow(operator: g.witness.structural.evidence) + } + } + } } } @@ -1944,8 +2961,8 @@ type InferApplicationFormals | FormalsUnresolved | FormalsOrderRefused { reason: Symbol } -fn infer_application_formals(node: Node) -> InferApplicationFormals { - match infer_application_callee_arrow(node: node) { +fn infer_application_formals(node: Node, entries: List) -> InferApplicationFormals { + match infer_application_callee_arrow_with_facts(node: node, entries: entries) { Absent => FormalsUnresolved Present { value: arrow } => match list_at_optional(xs: node_positional_child_targets(node: arrow), index: 0) { @@ -2040,8 +3057,13 @@ fn infer_pairing_formal(p: InferFormalPairing) -> InhabitanceFormal { } // The callee's declared type parameters; empty when the operator is not an Arrow or declares none. -fn infer_application_type_params(node: Node) -> List { - match infer_application_callee_arrow(node: node) { +// `entries` REACHES infer_application_type_params AND MAIN'S CALL SITE HAD DROPPED IT. That reader asks +// infer_application_callee_arrow_with_facts, which finds a callee arrow reached THROUGH the use's facts +// and not only one that IS an Arrow node, so without the argument a declaration's arrow is invisible to +// it. Both spellings typecheck, which is why the argument is restored deliberately here: dropping it is +// the same silent deletion that cost this lane five claims once already. +fn infer_application_type_params(node: Node, entries: List) -> List { + match infer_application_callee_arrow_with_facts(node: node, entries: entries) { Absent => [] Present { value: arrow } => type_param_names(n: arrow) } @@ -2080,6 +3102,22 @@ fn infer_inhabitance_undecidable_accepted( // that is a bare atom the join does not denote, and not one of the type variables, is not a type // this relation can compare: it is counted UndecidableFormalUnresolved, never refused for the // spelling and never admitted silently. +// +// A DECLARED POSITION THAT ALREADY CARRIES ITS VALUE TYPE IS DECIDABLE, AND THE JOIN CANNOT SAY SO. +// dag_binding_denotation is strictly BINDING->value-type, and `Bool` does not reach this reader as a +// binding: it arrives as the DENOTED node already (v2.std.logic bool_node), so the join answers Absent +// and the position was counted rather than compared. `Int` masked it, because its canonical type +// constructor retains the historical spelling ^dag_binding_type_int and its binding and type identities +// coincide -- so a declared-Int mismatch refused while the identical declared-Bool mismatch was +// accepted at the frontier. MEASURED, not inferred: for `fn wrong(only_arg: Int) -> Bool { only_arg }` +// the Arrow's declared return is structurally bool_node(), and for the converse fixture it is +// ^dag_binding_type_int. +// +// SO THE RECOGNITION IS ASKED BY AUTHORITY, NOT BY SPELLING: infer_established_value_type_optional +// compares against v2.std.logic's own bool_node() through the existing structural equality. That is one +// authority consumed by both this gate and infer_declared_type_denotation, rather than a second +// recognition here or a widening of dag_binding_denotation to accept a denoted symbol -- the join stays +// strictly binding-to-type. An arbitrary atom is still not a resolved value type and is still counted. fn infer_declared_position_undecidable_reason( declared: Node, type_params: List @@ -2091,7 +3129,11 @@ fn infer_declared_position_undecidable_reason( TypeNode { connective: Atom { identity: id } } => match dag_binding_denotation(sym: id) { Present { value: _ } => Absent - Absent => optional_present(value: undecidable_formal_unresolved()) + Absent => + match infer_established_value_type_optional(ret: declared) { + Present { value: _ } => Absent + Absent => optional_present(value: undecidable_formal_unresolved()) + } } _ => Absent } @@ -2107,7 +3149,11 @@ fn infer_declared_type_denotation(declared: Node) -> Node { TypeNode { connective: Atom { identity: id } } => match dag_binding_denotation(sym: id) { Present { value: denoted } => denoted - Absent => Node { kind: self.kind, children: [], occurrence_id: self.occurrence_id } + Absent => + match infer_established_value_type_optional(ret: self) { + Present { value: established } => established + Absent => Node { kind: self.kind, children: [], occurrence_id: self.occurrence_id } + } } _ => Node { kind: self.kind, children: [], occurrence_id: self.occurrence_id } } @@ -2254,7 +3300,7 @@ fn infer_application_argument_inhabitance( entries: List, declarations: SymbolIndex ) -> Outcome { - match infer_application_formals(node: node) { + match infer_application_formals(node: node, entries: entries) { FormalsUnresolved => infer_inhabitance_undecidable_accepted( application: node, @@ -2263,7 +3309,7 @@ fn infer_application_argument_inhabitance( FormalsOrderRefused { reason: reason } => outcome_rejected(application_parameter_order_diagnostic(application: node, reason: reason)) FormalsInDeclaredOrder { formals: formals } => - match infer_application_formal_args(node: node, formals: formals) { + match infer_application_formal_args(node: node, formals: formals, entries: entries) { Rejected { diagnostics: r } => Rejected { diagnostics: r } Accepted { value: pairs, diagnostics: _ } => match infer_judge_formal_args( @@ -2272,7 +3318,7 @@ fn infer_application_argument_inhabitance( entries: entries, declarations: declarations, pairs: pairs, - type_params: infer_application_type_params(node: node) + type_params: infer_application_type_params(node: node, entries: entries) ) { Rejected { diagnostics: r } => Rejected { diagnostics: r } Accepted { value: _, diagnostics: d } => Accepted { value: true, diagnostics: d } @@ -2343,9 +3389,28 @@ fn infer_judge_formal_args( // bound twice -- refuses here, located at the actual it names (the application itself when the plan // names no actual). This replaces a zip of stored formals with positional children by index, which // ignored every name (gunbc#12054). -fn infer_application_formal_args(node: Node, formals: List) -> Outcome> { +// THE ARROW IS READ THROUGH THE CALLEE'S FACTS, THE SAME WAY ITS FORMALS WERE. This read used the +// facts-blind reader while its caller's formals came from infer_application_callee_arrow_with_facts, +// so a NAMED call -- whose callee is a resolved declaration reference, a Conj and never an Arrow -- +// answered Absent here and returned NO formal/actual pairs. The inhabitance fold over an empty list +// accepts unconditionally, so every argument of every named call went unjudged: a Bool actual filled +// an Int formal and the application typed past it. Nothing reported it, because the two readers +// disagreed about the same question. ONE reader now answers it for both, so the formals a caller +// admitted and the plan that binds them come from the same arrow or from neither. +// +// WHY THE FAIL-OPEN WAS INVISIBLE: the application's RESULT typing read the same arrow through +// infer_arrow_declared_return_type, which was itself failing on a declaration whose return atom was +// the authored spelling rather than a resolved binding -- so the call never grounded, and the negative +// control asserting that an invalid-argument call does not ground passed for the wrong reason. Fixing +// the declaration lookup to read resolved bodies removed the mask and the control went red, which is +// how the fail-open surfaced at all. +fn infer_application_formal_args( + node: Node, + formals: List, + entries: List, +) -> Outcome> { let actuals = application_actual_edges(site: node) - match infer_application_callee_arrow(node: node) { + match infer_application_callee_arrow_with_facts(node: node, entries: entries) { Absent => outcome_accepted(value: Empty) Present { value: arrow } => match application_binding_plan(arrow: arrow, actuals: actuals) { @@ -2610,7 +3675,7 @@ fn infer_gather_match_row_on_entries( partials: List, resolved: ResolvedTree, ) -> InferGatherFoldAcc { - match infer_match_bool(node: node, partials: partials, entries: entries, resolved: resolved) { + match infer_match(node: node, partials: partials, entries: entries, resolved: resolved) { Rejected { diagnostics: r } => infer_gather_fold_acc_failed( node: node, @@ -3078,10 +4143,13 @@ fn infer_transform_derived_optional( // a non-Arrow operator, or a return the language join does not denote stays on the counted // frontier: Absent here, never an admission. fn infer_transform_application_optional(node: Node, entries: List) -> Optional> { - match infer_application_callee_arrow(node: node) { + match infer_application_callee_arrow_with_facts(node: node, entries: entries) { Absent => optional_absent() Present { value: arrow } => - match lookup_inferred_facts_in_entries(entries: entries, key: arrow) { + match infer_application_callee_use(node: node) { + Absent => optional_absent() + Present { value: callee_use } => + match lookup_inferred_facts_in_entries(entries: entries, key: callee_use) { Absent => optional_absent() Present { value: arrow_facts } => if !inferred_facts_grounding_derived(facts: arrow_facts) { @@ -3104,6 +4172,7 @@ fn infer_transform_application_optional(node: Node, entries: List, pending: Diagnostics, partials: List, + resolved: ResolvedTree, ) -> InferGatherFoldAcc { infer_gather_formed_facts_row( node: node, - formed: infer_formation_facts_from_entries(node: node, entries: entries, partials: partials), + formed: infer_formation_facts_from_entries( + node: node, + entries: entries, + partials: partials, + resolved: resolved + ), entries: entries, pending: pending ) @@ -3650,6 +4725,59 @@ fn infer_disj_is_named_sum(n: Node) -> Bool { // constructor's fields; the match row reads it from the arm, and it is never judged as the // construction it is shaped like. The same edge is outside v2.std.type_binder // node_inferred_subtree_nodes. +// A WHERE-REFINEMENT'S PREDICATE CALLS ARE NOT JUDGED AS APPLICATIONS, AND EACH ONE SAYS SO. A predicate +// call (v2.compiler.body_lowering_fold body_lower_kept_where_clause) applies its declaration to the REFINED +// VALUE, which the clause never writes: `type Pos = Int where positive` lowers to a zero-argument call of +// `positive(x: Int)`. Judged as an ordinary named call through application_binding_plan it refuses +// application_positional_deficit on every refinement, and judged with the subject supplied it needs a slot no +// carrier has: which formal is the subject is stated only as an annotation in std.types, and `brand` takes no +// subject at all. So this is the SAME declared frontier RefinementDeclaration.where_clause carries -- the +// where clause is not yet consumed by infer -- extended to the calls inside it, not a second one. +// +// IT IS NOT A RUNG DROP. Before named calls were judged at all, a predicate call was accepted unjudged along +// with every other named call; what this arm keeps is that single shape at that rung, while every other named +// call stays judged (a positional deficit still refuses: v2.test.claim.parameter_reference_test +// pr_ordinary_named_call_deficit_still_refuses). IT IS LOUD, NOT SILENT: each predicate call emits +// inhabitance_undecidable_where_predicate_subject_unmodelled at its own locus, so the population is counted by +// execution over any compiled corpus rather than transcribed here. PARKED; NO INFER JUDGMENT IS COMING +// (operator ruling, 2026-10-02): refinements are being deleted, replaced by checked constructors returning +// Optional. NEXT TRIGGER, NAMING THE CAPABILITY: where-refinement clauses are removed from the language in +// favour of checked constructors returning Optional, SUFFICIENT FOR no where-predicate call to reach infer -- +// then this arm and its reason are deleted with the clause. +fn infer_where_predicate_set_edge(parent: Node, edge: Edge) -> Bool { + match edge.label { + Positional => false + StructuralLabel { label: _ } => false + Authored { name: label } => + if label == ^grammar_production_captured_node_projection { + match find_named_child(root: parent, name: ^grammar_production_identity_node_projection) { + Accepted { value: identity, diagnostics: _ } => + match identity.kind { + TypeNode { connective: Atom { identity: emitted } } => emitted == ^dag_surface_where_refinement_clause + TypeNode { connective: _ } => false + ComputationNode { behavior: _ } => false + } + Rejected { diagnostics: _ } => false + } + } else { + false + } + } +} + +fn infer_where_predicate_calls_unjudged(set: Node) -> Diagnostics { + fold(set.children, init: None, f: fn(acc, e) { + diagnostics_merge( + outer: acc, + inner: Some { + diagnostics: diagnostics_singleton( + d: inhabitance_undecidable_diagnostic(application: e.target, reason: undecidable_where_predicate_subject_unmodelled()) + ) + } + ) + }) +} + fn infer_gather_fold_step( acc: InferGatherFoldAcc, edge: Edge, @@ -3673,6 +4801,15 @@ fn infer_gather_fold_step( kinds: kinds, resolved: resolved ) + } else if infer_where_predicate_set_edge(parent: acc.node, edge: edge) { + infer_gather_fold_step_merged( + acc: acc, + merged_entries: acc.entries, + merged_pending: diagnostics_merge(outer: acc.pending, inner: infer_where_predicate_calls_unjudged(set: edge.target)), + partials: partials, + kinds: kinds, + resolved: resolved + ) } else if child.failed { infer_gather_fold_acc_failed( node: acc.node, @@ -4398,6 +5535,12 @@ fn infer_arrow_declared_return_shape_diagnostic(node: Node) -> Diagnostic { } } +// THE RESOLVED CARRIER REACHES THIS ROW BECAUSE THE PRODUCT ROW BENEATH IT ANSWERS A FIELD +// PROJECTION FIRST, and a projection needs the receiver's DECLARATION, which only the index in +// ResolvedTree supplies (infer_formation_facts_from_entries). Main introduced this row when it split +// the declared-return check out of infer_gather_settled_row; this lane had widened the product row it +// delegates to. The two are compatible -- the enclosing row already holds `resolved` -- and the +// parameter is threaded rather than reconstructed, so there is one carrier for the whole fold. fn infer_gather_settled_unannotated_row( acc: InferGatherFoldAcc, merged_entries: List, @@ -4421,7 +5564,8 @@ fn infer_gather_settled_unannotated_row( node: acc.node, entries: merged_entries, pending: merged_pending, - partials: partials + partials: partials, + resolved: resolved ) } } else { diff --git a/src/v2/compiler/05_eval.dag b/src/v2/compiler/05_eval.dag index d0cfbf90c5f..5e78cccfa27 100644 --- a/src/v2/compiler/05_eval.dag +++ b/src/v2/compiler/05_eval.dag @@ -34,7 +34,9 @@ import v2.std.collection { list_at_optional, list_nth } -import v2.std.qualified_name { declaration_reference_path_optional, lexical_reference_label_optional, parameter_reference_path_optional } +import v2.std.qualified_name { + qualified_name_snoc, + qualified_name_to_dotted_string, declaration_reference_path_optional, lexical_reference_label_optional, parameter_reference_path_optional } import v2.std.optional { Absent, Optional, @@ -59,6 +61,9 @@ import v2.std.runtime { ValueInterpreter, Return, RuntimeAggregate, + RuntimeFieldFound, + RuntimeFieldMissing, + runtime_aggregate_field_selection, RuntimeClosure, RuntimeClosureValue, RuntimePrimitive, @@ -91,6 +96,7 @@ import v2.std.runtime { runtime_value_resolved_type } import v2.std.node { + symbol_intern_lexeme, StructuralLabel, positional_edges, ArrowBodyForm, @@ -134,7 +140,13 @@ import v2.std.node { node_for_structural_equality, well_formed, } -import v2.std.node_query { find_arrow_body_child, node_positional_child_targets } +import v2.std.node_query { + FieldProjection, + field_projection_edge_base_optional, + field_projection_optional, + find_arrow_body_child, + node_positional_child_targets +} import v2.std.diagnostic { AmbiguousIntent, ByteRange, @@ -1327,6 +1339,14 @@ fn eval_lexical_reference(node: Node, label: Symbol, environment: EvaluationEnvi // accepted program reaches this arm. DECLARED FRONTIER, TRIGGER NAMING THE CAPABILITY: gunbc#12506's // named-call route evaluates a declared fn body over resolver output, SUFFICIENT FOR eval to key // parameters by QualifiedName with this refusal arm deleted. +// +// THE REFERENCE CARRIES ITS OWN KEY, so the body looks the actual up under the path the call bound it +// at -- the same key eval_parameter_binding_key produced, which is why that constructor is the only +// place either side derives one. The refusal that stood here answered for EVERY parameter reference, +// which was honest while no route evaluated a declared body over resolver output and became a wall +// against the capability once one did. It does not disappear: a reference whose path the environment +// does not hold still refuses with the same reason, so a wrong path or an unbound parameter is still +// a located refusal and only a BOUND one now executes. fn eval_type_node_atom( node: Node, interpretation: InterpretationAlgebra, @@ -1339,10 +1359,14 @@ fn eval_type_node_atom( ) Absent => match parameter_reference_path_optional(node: node) { - Present { value: _ } => - outcome_rejected( - d: eval_diagnostic(reason: ^eval_rejected_parameter_reference_unbound, node: node) - ) + Present { value: path } => + match environment.bindings.lookup(eval_parameter_binding_key(parameter_path: path)) { + Present { value: v } => Accepted { value: v, diagnostics: None } + Absent => + outcome_rejected( + d: eval_diagnostic(reason: ^eval_rejected_parameter_reference_unbound, node: node) + ) + } Absent => match lexical_reference_label_optional(node: node) { Present { value: label } => eval_lexical_reference(node: node, label: label, environment: environment) @@ -1380,12 +1404,67 @@ fn eval_type_node_atom( // for each declared parameter, which of them fills it -- the same plan v2.compiler.infer checked, so // the two stages cannot bind one call differently. A domain with no named binder keeps its positional // edges, bound in their own order. +// THE PARAMETER BINDING KEY, AND IT IS ONE CONSTRUCTOR BECAUSE BINDING AND LOOKUP MUST NOT DISAGREE. +// gunbc#12766 made a named fn's parameter use a PATH-KEYED parameter reference and declared eval's keying a +// frontier whose trigger is this lane's named-call route. Binding by the bare label while the body looks the +// parameter up by its declaring path is exactly the two-authorities defect that frontier existed to prevent, +// so the key is derived here and nowhere else: the callee's declaring path, then the formal's label. +// +// THE FULL PATH, NEVER THE LEAF. `p.f.x` and `p.g.x` are different parameters of different declarations, and +// keying on `x` would alias them -- one function's actual answering another function's body. That is the same +// reasoning v2.compiler.infer records for symbol_index_lookup, which asks for the whole declaring path +// precisely because a leaf answer fabricated a type from an unrelated declaration. +// +// THE KEY IS INTERNED, because EnvironmentBindingKey carries a Symbol (v2.std.runtime) rather than a path, +// and the dotted rendering is v2.std.qualified_name's own. A separator that could occur inside a segment +// would make two distinct paths collide, and `.` cannot: it is the segment separator of the very grammar +// these names come from. +fn eval_parameter_binding_key(parameter_path: QualifiedName) -> EnvironmentBindingKey { + environment_binding_key_for_symbol( + sym: symbol_intern_lexeme(lexeme: qualified_name_to_dotted_string(qn: parameter_path)) + ) +} + +// WHICH KEYING A FORMAL GETS, decided once. A call whose callee is a resolved declaration reference supplies +// a declaring path, so its formals are keyed by path. A LAMBDA supplies none: gunbc#12766 deliberately left +// lambda parameters on their lexical bare-label route, because an anonymous Arrow has no index path to key +// by, so Absent keeps exactly the behaviour that route already had. This is not a fallback for the +// path-keyed case -- a named declaration NEVER keys by bare label, because binding both spellings would make +// the route look repaired while preserving the two authorities the frontier forbids. +fn eval_formal_binding_key(callee_path: Optional, label: Symbol) -> EnvironmentBindingKey { + match callee_path { + Present { value: path } => + eval_parameter_binding_key(parameter_path: qualified_name_snoc(qn: path, segment: label)) + Absent => environment_binding_key_for_symbol(sym: label) + } +} + +fn eval_parameter_reference_is_value(node: Node) -> Bool { + match parameter_reference_path_optional(node: node) { + Present { value: _ } => true + Absent => false + } +} + +fn eval_parameter_reference_value(node: Node, environment: EvaluationEnvironment) -> Outcome { + match parameter_reference_path_optional(node: node) { + Absent => outcome_rejected(d: eval_diagnostic(reason: ^eval_rejected_parameter_reference_unbound, node: node)) + Present { value: path } => + match environment.bindings.lookup(eval_parameter_binding_key(parameter_path: path)) { + Present { value: v } => Accepted { value: v, diagnostics: None } + Absent => + outcome_rejected(d: eval_diagnostic(reason: ^eval_rejected_parameter_reference_unbound, node: node)) + } + } +} + fn eval_bind_arrow_params( arrow: Node, args: List, environment: EvaluationEnvironment, bind: BindInterpreter, - node: Node + node: Node, + callee_path: Optional ) -> Outcome { bind_outcome( o: eval_nth_child(node: arrow, wanted: 0, absent_reason: ^eval_rejected_callee_absent), @@ -1405,7 +1484,13 @@ fn eval_bind_arrow_params( absent: eval_diagnostic(reason: ^eval_rejected_argument_bind_absent, node: node) ) ), - f: fn(value) { bind.bind_value(environment_binding_key_for_symbol(sym: slot.label), value, env) } + f: fn(value) { + bind.bind_value( + eval_formal_binding_key(callee_path: callee_path, label: slot.label), + value, + env + ) + } ) }) }) @@ -1469,19 +1554,65 @@ fn eval_node_is_arrow(node: Node) -> Bool { } } +// A RESOLVED DECLARATION REFERENCE IS A CALLEE REFERENCE, RECOGNIZED THROUGH ITS MARKER READER AND +// NOT BY ITS CONNECTIVE. The carrier is a marked Conj (v2.std.qualified_name +// declaration_reference_node), and admitting Conj as such would admit every record shape with it -- +// so the third arm asks declaration_reference_path_optional, the same reader infer and translate ask. +// +// WHAT THIS FIXES IS THE WALK BEFORE IT IS THE DISPATCH. Until the classifier recognized the +// reference, eval_fold_is_callee_reference_edge answered false, eval_fold_child_for_edge took its +// ordinary recursive arm, and the walk DESCENDED INTO THE REFERENCE'S ENCODED DECLARING PATH -- +// demanding value-grounding for the internal representation of a declaration identity. That spine is +// built at OccurrenceSynthetic because the authored occurrence belongs to the marker node, and infer +// visits it too, so each component held an entry with grounding underived and the walk refused with +// eval_rejected_grounding_not_derived anchored on path data. The regression control for exactly that +// anchor is v2.test.claim.callexec.declaration_reference_eval. +// // A lexical reference is the name a bare Atom callee was before resolve marked it, so it routes as one. fn eval_node_is_callee_reference(node: Node) -> Bool { match node.kind { TypeNode { connective: Arrow } => true TypeNode { connective: Atom { identity: _ } } => true _ => - match lexical_reference_label_optional(node: node) { + match declaration_reference_path_optional(node: node) { Present { value: _ } => true - Absent => false + Absent => + match lexical_reference_label_optional(node: node) { + Present { value: _ } => true + Absent => false + } + } + } +} + +// THE EXECUTABLE DECLARATION A CALLEE REFERENCE NAMES, taken from the denotation infer recorded with +// the guarded index lookup (v2.compiler.inferred_tree InferredFacts denotation). eval does not +// re-resolve: it holds no symbol index, and a second resolution path here would be a weaker authority +// that could accept a reference the guard refuses. +// +// A REFERENCE WHOSE FACTS ARE ABSENT OR CARRY NO DENOTATION REFUSES, and refuses as a missing runtime +// binding rather than being guessed at, inlined by name, or silently treated as a primitive. +fn eval_callee_declaration_optional(tree: InferredTree, callee: Node) -> Optional { + match declaration_reference_path_optional(node: callee) { + Absent => optional_absent() + Present { value: _ } => + match tree.facts.lookup(callee) { + Absent => optional_absent() + Present { value: facts } => facts.denotation } } } +// THE NODE THE CALL DISPATCHES THROUGH. For an ordinary Arrow callee it is the callee itself, exactly +// as before; for a declaration reference it is the DENOTED declaration. Returning the callee unchanged +// when no denotation is reachable keeps the existing refusal arms in charge of saying so. +fn eval_callee_dispatch_node(tree: InferredTree, callee: Node) -> Node { + match eval_callee_declaration_optional(tree: tree, callee: callee) { + Present { value: declaration } => declaration + Absent => callee + } +} + fn eval_child_absent(node: Node, wanted: Int) -> Bool { match eval_nth_child( node: node, @@ -1712,6 +1843,20 @@ fn eval_callee_body_refusal_reason(form: ArrowBodyForm) -> Symbol { } } +// THE CALL DISPATCHES THROUGH THE DENOTED DECLARATION WHEN ITS CALLEE IS A REFERENCE, and through the +// callee itself otherwise. `callee_target` below is that one node, read by both the body lookup and +// the parameter binding, so a reference binds the CALLEE's own formals against the CALLEE's own body +// -- never a body from one declaration paired with formals from another. +// +// A REFERENCE THAT REACHED NO EXECUTABLE DECLARATION REFUSES AS AN UNBOUND RUNTIME BINDING. Either no +// facts entry carried a denotation -- so infer refused the reference's contract -- or the denoted +// declaration is not an Arrow with an admissible body. Neither arm falls through to the primitive +// table, where a reference would be looked up by a name it does not have and reported as a missing +// primitive rather than as the declaration it actually names. +// THE CALLEE'S OWN DECLARING PATH, read off the reference the call carries rather than +// re-resolved: eval is a consumer of resolution's answer here, never a second naming +// authority. A callee that is not a declaration reference (a lambda) yields Absent and +// its formals stay on the lexical route. fn eval_transform_node( node: Node, args: List, @@ -1739,18 +1884,20 @@ fn eval_transform_node( bind_outcome( o: eval_transform_callee_edge(node: node), f: fn(callee_edge) { - match find_arrow_body_child(root: callee_edge.target) { + let callee_target = eval_callee_dispatch_node(tree: tree, callee: callee_edge.target) + match find_arrow_body_child(root: callee_target) { Accepted { value: body, diagnostics: _ } => let body_form = classify_arrow_body_form(target: body) - if eval_arrow_admits_callee_dispatch(arrow: callee_edge.target) + if eval_arrow_admits_callee_dispatch(arrow: callee_target) && arrow_body_admits_eval_entry(form: body_form) { bind_outcome( o: eval_bind_arrow_params( - arrow: callee_edge.target, + arrow: callee_target, args: args, environment: environment, bind: interpretation.bind, - node: node + node: node, + callee_path: declaration_reference_path_optional(node: callee_edge.target) ), f: fn(call_environment) { match body_form { @@ -1776,15 +1923,27 @@ fn eval_transform_node( outcome_rejected( d: eval_diagnostic( reason: eval_callee_body_refusal_reason(form: body_form), - node: callee_edge.target + node: callee_target ) ) } Rejected { diagnostics: _ } => - if eval_node_is_arrow(node: callee_edge.target) { + if eval_node_is_arrow(node: callee_target) { outcome_rejected( - d: eval_diagnostic(reason: ^eval_rejected_unsupported_callee, node: callee_edge.target) + d: eval_diagnostic(reason: ^eval_rejected_unsupported_callee, node: callee_target) ) + } else if eval_node_is_callee_reference(node: callee_edge.target) + && !eval_node_is_arrow(node: callee_edge.target) { + match declaration_reference_path_optional(node: callee_edge.target) { + Present { value: _ } => + outcome_rejected( + d: eval_diagnostic( + reason: ^eval_rejected_runtime_binding_lookup_miss, + node: callee_edge.target + ) + ) + Absent => transform.call_primitive(node, args, environment) + } } else { transform.call_primitive(node, args, environment) } @@ -2173,16 +2332,89 @@ fn eval_interpret_node( effect_io: effect_io ) TypeNode { connective: _ } => - eval_type_node_atom( - node: node, - interpretation: interpretation, - environment: environment - ) + match field_projection_optional(n: node) { + Present { value: projection } => + eval_field_projection(node: node, projection: projection, args: args) + Absent => + eval_type_node_atom( + node: node, + interpretation: interpretation, + environment: environment + ) + } } } ) } +// A FIELD PROJECTION SELECTS A FIELD FROM THE RECEIVER'S VALUE, and refuses on anything else. Without +// this arm a projection reached the default TypeNode route and was handed to allocate_literal, which +// would fabricate a literal out of a two-edge Conj -- a value for an expression that selects, not one +// that constructs. +// +// THE RECEIVER'S VALUE ARRIVES AS THE ONE RUNTIME ARGUMENT, through the same seam the evaluator already +// uses to decide which children are values (eval_edge_is_runtime_argument). So the base is evaluated +// ONCE, by the ordinary walk, rather than re-evaluated here: re-entering the base would compute it a +// second time for every projection off it, which is the duplicated work DESIGN section 2 forbids at any +// n. The FIELD edge is deliberately not an argument -- it carries a name, not a value, and evaluating it +// would ask for the grounding of a field-name atom. +// +// EVERY NON-SELECTING CASE REFUSES, TYPED AND LOCATED. A receiver that is not an aggregate has no +// fields to select from; an aggregate that does not carry the field is a value disagreeing with the type +// that admitted the projection, which is a fail-closed refusal here rather than a fabricated default, +// because infer has already established that the receiver's type declares the field +// (v2.compiler.infer infer_field_projection_facts) -- so reaching this arm means the VALUE and the TYPE +// disagree, and that is worth its own reason rather than being folded into "no such field". +fn eval_field_projection( + node: Node, + projection: FieldProjection, + args: List +) -> Outcome { + match list_at_optional(xs: args, index: 0) { + Absent => + outcome_rejected( + d: eval_diagnostic(reason: ^eval_rejected_projection_receiver_absent, node: node) + ) + Present { value: receiver } => + eval_field_projection_of_receiver( + node: node, + projection: projection, + receiver: receiver + ) + } +} + +// THE RECEIVER CROSSES INTO A TYPED PARAMETER BEFORE ANY FIELD IS READ. list_at_optional answers +// Optional, and a value destructured straight out of it does not carry RuntimeValue through a field +// access -- measured: reading `aggregate.fields` inline produced a runtime type error while the +// non-aggregate arm, which reads no field, passed. Naming the parameter is what restores the type, and it +// is the same shape v2.std.runtime runtime_fields_list_node already uses for its own element. +fn eval_field_projection_of_receiver( + node: Node, + projection: FieldProjection, + receiver: RuntimeValue +) -> Outcome { + match receiver { + RuntimeAggregate { value: aggregate } => + match runtime_aggregate_field_selection(fields: aggregate.fields, name: projection.field) { + RuntimeFieldFound { value: field_value } => + Accepted { value: field_value, diagnostics: None } + RuntimeFieldMissing => + outcome_rejected( + d: eval_diagnostic( + reason: ^eval_rejected_projected_field_absent_from_value, + node: node + ) + ) + } + _ => + outcome_rejected( + d: eval_diagnostic(reason: ^eval_rejected_projection_receiver_not_aggregate, node: node) + ) + } +} + + fn empty_runtime_values() -> List { [] } @@ -2213,7 +2445,14 @@ fn eval_edge_is_runtime_argument(parent: Node, progress: EvalProgress, edge: Edg } else if eval_node_is_transform(node: parent) { !edge_is_cast_target(e: edge) } else { - is_positional(e: edge) + match field_projection_optional(n: parent) { + Present { value: _ } => + match field_projection_edge_base_optional(e: edge) { + Present { value: _ } => true + Absent => false + } + Absent => is_positional(e: edge) + } } } @@ -2306,6 +2545,17 @@ fn eval_fold_control_transfer_state( } } +// THIS PREDICATE IDENTIFIES THE CALLEE EDGE BY A PROCESSED COUNT, WHICH IS A STANDING OBSERVATION AND +// NOT A REPAIR THIS FUNCTION CONTAINS. `p == 0 && is_positional(edge)` names the callee only while the +// callee is the first child the fold happens to process, so the discriminator is child ORDER rather +// than the callee -- and eval_transform_callee_edge already answers the question directly. +// +// IT IS LEFT ALONE BECAUSE REWRITING IT CHANGED NO VERDICT. Keying on eval_transform_callee_edge was +// written and measured: all seven controls in v2.test.claim.callexec.declaration_reference_eval pass +// either way, in both directions. A second formulation nothing needs is redundant work (DESIGN section +// 2), so the count-based test stays and the observation is recorded here rather than acted on. Whoever +// finds a call shape where the two DISAGREE has the discriminating case this lacked, and that case is +// what would justify the change. fn eval_fold_is_callee_reference_edge( parent: Node, progress: EvalProgress, @@ -2673,6 +2923,16 @@ fn eval_runtime_node_via_data_flow_fold( eval_fold_state_value(state: folded) } +// A PARAMETER REFERENCE IS A LEAF HERE, NOT A STRUCTURE TO WALK. Its carrier is a marked Conj whose child +// is the encoded declaring path (v2.std.qualified_name parameter_reference_node), so letting the data-flow +// fold see it descends into that path and demands value-grounding for the internal representation of a +// parameter identity -- the same defect this module already fixed for DECLARATION references, recorded at +// eval_fold_is_callee_reference_edge, and the reason its regression control exists. +// +// IT RESOLVES THROUGH THE SAME KEY THE CALL BOUND IT AT, so eval_parameter_binding_key stays the only +// place either side derives a key. A reference whose path the environment does not hold still refuses with +// ^eval_rejected_parameter_reference_unbound, which is now a statement about a MISSING BINDING rather than +// about the body's form. fn eval_runtime_node( node: Node, tree: InferredTree, @@ -2681,7 +2941,9 @@ fn eval_runtime_node( runtime: V4EvaluatorRuntime, effect_io: EffectIoEvalContext ) -> Outcome { - if eval_runtime_node_is_control_transfer(node: node) { + if eval_parameter_reference_is_value(node: node) { + eval_parameter_reference_value(node: node, environment: environment) + } else if eval_runtime_node_is_control_transfer(node: node) { eval_runtime_control_node( node: node, tree: tree, diff --git a/src/v2/compiler/body_lowering_fold.dag b/src/v2/compiler/body_lowering_fold.dag index 1eb52cb08d6..a28822ffd08 100644 --- a/src/v2/compiler/body_lowering_fold.dag +++ b/src/v2/compiler/body_lowering_fold.dag @@ -4062,8 +4062,9 @@ fn body_lower_fn_decl_to_arrow(shell: Node) -> Outcome { // THE SIGNATURE OF A FN DECLARATION, READ ONCE FOR BOTH GRADES. The full arm and the census arm build // one Arrow from one set of reads; only whether a body edge follows differs. So the reads live here -// and the Arrow is built by body_lower_fn_decl_arrow -- its ONE child list, with the body edge -// present or absent -- and "census grade is the same signature" holds +// and the Arrow is built by body_lower_fn_decl_arrow, which projects this signature onto the one +// callable-Arrow constructor (body_lower_callable_arrow) with the body edge present or absent, and +// "census grade is the same signature" holds // by construction rather than by two copies staying in step. Absent means a signature this lowering // cannot read, which both arms retain as the shell. type BodyLowerFnSignature { @@ -4233,20 +4234,16 @@ fn body_lower_data_decl_to_member(shell: Node) -> Outcome { ) Present { value: data_name } => let signature = declared_signature(params: [], source: shell) - let arrow = close_lowered_image(root: node_lowered_from( + let arrow = body_lower_callable_arrow( source: shell, - kind: TypeNode { connective: Arrow }, - children: [ - signature.domain, - Edge { label: Positional, target: declared_type }, - Edge { label: Positional, target: declared_type }, - signature.order, - Edge { - label: core_edge_label(marker: ArrowBodyEdge), - target: body_lower_position_function_values(node: reified_body, path: body_lower_unpositioned_path()) - } - ] - )) + domain: signature.domain, + codomain: declared_type, + order_edge: signature.order, + type_param_edges: body_lower_no_type_param_edges(), + body: optional_present( + value: body_lower_position_function_values(node: reified_body, path: body_lower_unpositioned_path()) + ) + ) body_lowering_lowered( o: outcome_with_diagnostics( value: body_lower_fn_decl_named_member_wrap( @@ -4275,29 +4272,76 @@ fn body_lower_data_decl_to_member(shell: Node) -> Outcome { // place positional binding reads parameter order (v2.std.node arrow_declared_parameter_order). // Resolve does not walk it (v2.compiler.resolve resolve_arrow_node): its atoms are labels, not // references, so a parameter spelled like a name in scope cannot bind through it. +// +// THE ONE CALLABLE ARROW. A lambda is surface sugar over the same Arrow a named `fn` declares, so the +// child LAYOUT of a callable Arrow -- which children exist, in which order, under which labels -- is +// decided here and nowhere else: the three callables (a named `fn` declaration, a `data` declaration +// as the nullary case, and a function value written `fn(..) { .. }` or `x => e`) differ only in what +// they can READ, never in what they build. Before this constructor the layout was authored three +// times, so a change to it -- notably removing the duplicated codomain below -- was three edits that +// had to stay in step, and nothing made them. +// +// WHAT EACH CALLER STILL DECIDES, because it is a fact about the caller and not about the Arrow: the +// domain and order edges arrive built, since a declared signature mints both from ONE +// anonymous-binder pass (v2.std.arrow_signature declared_signature) at the occurrence of the text +// that spelled the parameters, which is not always this Arrow's own shell; the type-parameter edges +// arrive as the 0-or-1 list each caller's own type-parameter fact projects to, because a declaration +// carries the Conj the parse captured while a function value mints one for its elided types; and the +// body arrives already prepared, because a declaration's lowering is the only place that holds the +// declaration's body root and can therefore position its fresh type variables +// (body_lower_position_function_values), while a function value nested inside one is positioned by +// that same walk from above rather than here. Absent body is the census grade and the signature-only +// arm, not a callable without one. +// +// THE CODOMAIN IS EMITTED TWICE, AS POSITIONAL CHILDREN 1 AND 2. No reader reads an Arrow's +// positional index 2; it is retained because changing the layout reaches every Arrow reader in the +// corpus, and that cut is owned by gunbc#12625 (Program P: one Arrow encoding). Its value here is +// that after this consolidation the duplication has ONE producer, so that cut is one edit. +fn body_lower_callable_arrow( + source: Node, + domain: Edge, + codomain: Node, + order_edge: Edge, + type_param_edges: List, + body: Optional +) -> Node { + let no_edges: List = [] + let body_edges: List = match body { + Absent => no_edges + Present { value: b } => [Edge { label: core_edge_label(marker: ArrowBodyEdge), target: b }] + } + close_lowered_image(root: node_lowered_from( + source: source, + kind: TypeNode { connective: Arrow }, + children: list_append( + left: list_append( + left: [ + domain, + Edge { label: Positional, target: codomain }, + Edge { label: Positional, target: codomain }, + order_edge + ], + right: type_param_edges + ), + right: body_edges + ) + )) +} + fn body_lower_fn_decl_arrow(shell: Node, sig: BodyLowerFnSignature, body: Optional) -> Node { - let signature = list_append( - left: [ - Edge { label: Positional, target: sig.domain }, - Edge { label: Positional, target: sig.return_type }, - Edge { label: Positional, target: sig.return_type }, - signature_order_edge(order: sig.order, source: shell) - ], - right: body_lower_fn_decl_type_params_edges(sig: sig) - ) body_lower_fn_decl_named_member_wrap( - arrow: close_lowered_image(root: node_lowered_from( + arrow: body_lower_callable_arrow( source: shell, - kind: TypeNode { connective: Arrow }, - children: match body { - Absent => signature + domain: Edge { label: Positional, target: sig.domain }, + codomain: sig.return_type, + order_edge: signature_order_edge(order: sig.order, source: shell), + type_param_edges: body_lower_fn_decl_type_params_edges(sig: sig), + body: match body { + Absent => optional_absent() Present { value: b } => - list_append( - left: signature, - right: [Edge { label: core_edge_label(marker: ArrowBodyEdge), target: body_lower_position_function_values(node: b, path: body_lower_unpositioned_path()) }] - ) + optional_present(value: body_lower_position_function_values(node: b, path: body_lower_unpositioned_path())) } - )), + ), fn_name: sig.fn_name, source: shell ) @@ -5122,25 +5166,17 @@ fn body_lower_function_value_arrow( [type_params_edge(conj: node_lowered_from(kind: TypeNode { connective: Conj }, children: type_params, source: shell))] } outcome_with_diagnostics( - value: close_lowered_image(root: node_lowered_from( + value: body_lower_callable_arrow( source: shell, - kind: TypeNode { connective: Arrow }, - children: list_append( - left: list_append( - left: [ - Edge { - label: Positional, - target: node_lowered_from(kind: TypeNode { connective: Conj }, children: sig.domain, source: shell) - }, - Edge { label: Positional, target: codomain_node }, - Edge { label: Positional, target: codomain_node }, - signature_order_edge(order: sig.order, source: shell) - ], - right: type_param_edges - ), - right: [Edge { label: core_edge_label(marker: ArrowBodyEdge), target: reified }] - ) - )), + domain: Edge { + label: Positional, + target: node_lowered_from(kind: TypeNode { connective: Conj }, children: sig.domain, source: shell) + }, + codomain: codomain_node, + order_edge: signature_order_edge(order: sig.order, source: shell), + type_param_edges: type_param_edges, + body: optional_present(value: reified) + ), diagnostics: bd ) } diff --git a/src/v2/compiler/fold_lowering.dag b/src/v2/compiler/fold_lowering.dag index 56cabe1d6ad..f2c93f57f5d 100644 --- a/src/v2/compiler/fold_lowering.dag +++ b/src/v2/compiler/fold_lowering.dag @@ -51,6 +51,7 @@ import v2.std.node { Positional, Symbol, TypeNode, + content_hash, is_empty_conj_root, node_synthetic, symbol_intern_lexeme, @@ -60,6 +61,7 @@ import v2.std.node { LoopRealizedDeclarationEdge, core_edge_label, } +import v2.std.anonymous_binder { fresh_fold_carrier } import std.decl_ref { DeclarationRef, WholeDeclaration } import v2.std.node_query { find_named_child, node_positional_child_targets } @@ -739,16 +741,34 @@ type FoldCallOperands { // child is the step as a function value, the member template of §2.2. ONE constructor, whatever the // grain of its operands: the raw call (read by the accumulator-copy lens) and the lowered operands // (built by v2.compiler.body_lowering_fold) meet here, so there is one encoding and not two. +// THE SLOT IS A GENERATED BINDER, NOT THE AUTHORED FORMAL. This constructor used to spell the Bind's +// binder and the carrier edge with operands.carrier -- the step's own first binder -- so the lowered tree +// held two visible binders with one name: the persistent slot and the step Arrow's freshly-bound formal. +// v2.compiler.resolve's value-binder admission refused the inner one, correctly, and the native eight +// refused at prepare with resolve_reason_binder_hides_visible_value at a synthetic "found". +// +// The slot now comes from v2.std.anonymous_binder fresh_fold_carrier, keyed by the STEP's content hash, so +// it is unauthorable, deterministic, derived from structure rather than occurrence or traversal order, and +// distinct wherever two folds' scopes can overlap. The step is still carried WHOLE, with both of its +// binders: a fold step is an ordinary callable, and dropping its first formal to let it read the slot +// instead would make fold lambdas a second kind of function value (DESIGN section 3). +// +// THE CARRIER REACHES THE STEP BY ROLE. The slot's current value is the step callable's actual 0 and the +// domain member is actual 1; that relation is positional and semantic. No consumer may recover one from +// the other by equal spelling, and after this mint none can: `<` is not an identifier character. +// operands.carrier remains the AUTHORED first binder and is what a consumer wanting the accumulator formal +// reads (fold_call_step_carrier) -- it is no longer the slot's identity. fn fold_recurrence_encoding(operands: FoldCallOperands) -> Node { + let slot = fresh_fold_carrier(step_digest: content_hash(n: operands.step).digest as String) lower_bind( - key_binding: fold_carrier_binder_atom(binder: operands.carrier), + key_binding: fold_carrier_binder_atom(binder: slot), value: operands.init, body_binding: node_synthetic( kind: ComputationNode { behavior: Loop }, children: [ Edge { label: Positional, target: operands.step }, Edge { label: core_edge_label(marker: LoopDomainEdge), target: operands.collection }, - Edge { label: core_edge_label(marker: LoopCarrierEdge), target: fold_carrier_binder_atom(binder: operands.carrier) }, + Edge { label: core_edge_label(marker: LoopCarrierEdge), target: fold_carrier_binder_atom(binder: slot) }, Edge { label: core_edge_label(marker: LoopBoundEdge), target: fold_iteration_measure_atom() }, Edge { label: core_edge_label(marker: LoopRealizedDeclarationEdge), target: operands.head } ] diff --git a/src/v2/compiler/inferred_tree.dag b/src/v2/compiler/inferred_tree.dag index 44014662b96..14e71ec6bf9 100644 --- a/src/v2/compiler/inferred_tree.dag +++ b/src/v2/compiler/inferred_tree.dag @@ -7,6 +7,7 @@ import std.algebra { list_snoc_item } import v2.std.optional { Absent, Optional, Present } import v2.std.constraints { CanonicalGrounding } import v2.std.node { Node } +import v2.std.optional { Optional, optional_absent } import v2.std.witness { Witness } import std.algebra { PartialFunction } @@ -32,9 +33,25 @@ type NodeGrounding = DerivedGrounding { grounding: CanonicalGrounding } | GroundingNotDerived { node: Node } +// WHAT A NODE DENOTES IS AN INFERRED FACT ABOUT IT, WHICH IS WHY IT LIVES HERE AND NOT IN A SECOND +// CARRIER. A resolved reference to a corpus declaration (v2.compiler.resolve resolved_reference_node) +// names a declaration, and the ONE place that may answer which declaration is the stage holding the +// symbol index -- infer, through the guarded reader v2.std.symbol_index symbol_index_lookup, whose +// ambiguity refusal is the whole point of it being guarded. Recording the answer here is what keeps +// that single authority: eval CONSUMES the denotation and never re-resolves, so there is no second, +// weaker reference-resolution path that could accept a reference the guarded reader refuses. +// +// IT IS A SEPARATE FIELD FROM THE GROUNDING, DELIBERATELY. The grounding carries the node's TYPE -- +// for a reference, its callable contract. Callable type evidence is not an executable body, and +// recovering the declaration's implementation out of the type slot would conflate the two facts; a +// consumer wanting the body reads the denotation, a consumer wanting the type reads the grounding. +// +// Absent is the ordinary case: only a node that denotes a declaration carries one, and a node that +// denotes nothing is not thereby defective. type InferredFacts { grounding: NodeGrounding descent: Witness + denotation: Optional } type InferredTree { diff --git a/src/v2/compiler/self_host/closure_emission.dag b/src/v2/compiler/self_host/closure_emission.dag index 4ce6083dcb9..80dc5fd8f1d 100644 --- a/src/v2/compiler/self_host/closure_emission.dag +++ b/src/v2/compiler/self_host/closure_emission.dag @@ -348,7 +348,7 @@ fn closure_resolve_member( policy: default_name_resolution_policy() )), admission: Admission { subject: ResolutionSubject { name: member_module }, imports: Empty } - ).walk, symbol_index: symbol_index) + ).walk, symbol_index: symbol_index, closure_declarations: empty_symbol_index()) } ) } diff --git a/src/v2/compiler/self_host/direct_rust_door_fixture.dag b/src/v2/compiler/self_host/direct_rust_door_fixture.dag index 297db5ddb0d..8f94b52a8d3 100644 --- a/src/v2/compiler/self_host/direct_rust_door_fixture.dag +++ b/src/v2/compiler/self_host/direct_rust_door_fixture.dag @@ -1,4 +1,5 @@ module v2.compiler.self_host.direct_rust_door_fixture +import v2.std.optional { optional_absent } import v2.compiler.refinement_discharge { infer_and_discharge } import extdeps.communication.medium { Lossless, Medium } @@ -288,7 +289,8 @@ fn direct_rust_door_inferred_facts_for(node: Node) -> Optional { optional_present( value: InferredFacts { grounding: DerivedGrounding { grounding: grounding }, - descent: Holds { value: proof } + descent: Holds { value: proof }, + denotation: optional_absent() } ) } diff --git a/src/v2/lens/complexity_accumulator_copy/analyze.dag b/src/v2/lens/complexity_accumulator_copy/analyze.dag index f4a4ff1e029..02438b5e818 100644 --- a/src/v2/lens/complexity_accumulator_copy/analyze.dag +++ b/src/v2/lens/complexity_accumulator_copy/analyze.dag @@ -201,7 +201,7 @@ fn call_port_readings(call_capture: Node) -> List Optional { v2.compiler.fold_lowering.fold_call_step_carrier(fold_call: call_capture) } diff --git a/src/v2/std/anonymous_binder.dag b/src/v2/std/anonymous_binder.dag index 2ee3d3d5bd4..af9bb98a6f1 100644 --- a/src/v2/std/anonymous_binder.dag +++ b/src/v2/std/anonymous_binder.dag @@ -65,6 +65,43 @@ fn fresh_type_variable(path: List, label: Symbol) -> Symbol { symbol_intern_lexeme(lexeme: fresh_type_variable_prefix() + p + ":" + symbol_lexeme(sym: label) + ">") } +// A FOLD'S PERSISTENT CARRIER IS A GENERATED VALUE BINDER, AND IT MAY NOT BORROW THE AUTHORED STEP +// PARAMETER'S SPELLING. The fold encoding is +// Bind { carrier := init, Loop { step, domain, carrier, bound, realized } }: the Bind's binder is a slot +// that persists ACROSS iterations, while the step's first formal is bound FRESHLY for one invocation of +// an ordinary callable. They hold the same value at the call boundary and they are not the same binding +// occurrence -- the author wrote only the formal, and the compiler introduces the slot. +// +// WHY THIS MINT EXISTS AT ALL: v2.compiler.fold_lowering used to spell the slot with the step's own first +// binder symbol, so the lowered tree held two visible binders with one name and v2.compiler.resolve's +// value-binder admission refused the inner one -- correctly, against the tree it received. The repair is +// here rather than in the admission: an exception keyed on "same spelling at a synthetic occurrence near a +// carrier edge" would put fold semantics inside the general binder law and rest on a heuristic, and +// OccurrenceSynthetic states only that there is NO authored occurrence, which is not a semantic identity. +// +// NAMED BY STRUCTURE, like the type variable above and for the same reason: the digest is the content hash +// of the STEP, so two folds whose scopes can overlap get different carriers. A nested fold's step strictly +// contains the inner fold, so their digests differ; two sibling folds with identical steps share a digest +// and that is harmless, because neither Bind's frame contains the other and the admission refuses only a +// binder hiding a VISIBLE one. It is not derived from an occurrence or from traversal order, so an edit +// elsewhere in the declaration does not rename it. +// +// THE RELATION TO THE STEP IS POSITIONAL, NEVER NOMINAL. A consumer that needs the step's accumulator +// formal reads the step callable's first declared parameter; a consumer that needs the slot reads the +// carrier edge. Nothing may infer one from the other by equal spelling -- `<` is not an identifier +// character, so after this mint they can never be equal. +fn fold_carrier_prefix() -> String { + " Symbol { + symbol_intern_lexeme(lexeme: fold_carrier_prefix() + step_digest + ">") +} + +fn is_fold_carrier_binder(sym: Symbol) -> Bool { + starts_with(s: symbol_lexeme(sym: sym), prefix: fold_carrier_prefix()) +} + fn fresh_return_type_variable(path: List) -> Symbol { fresh_type_variable(path: path, label: symbol_intern_lexeme(lexeme: "")) } diff --git a/src/v2/std/diagnostic.dag b/src/v2/std/diagnostic.dag index 9c15e91bdf2..fd5e345dcc1 100644 --- a/src/v2/std/diagnostic.dag +++ b/src/v2/std/diagnostic.dag @@ -333,6 +333,22 @@ fn bind_outcome_accepted(od: Diagnostics, inner: Outcome) -> Outcome { Rejected { diagnostics: rejected_with_pending(pending: od, rejected: r) } } } +// THE DIAGNOSTICS AN OUTCOME CARRIES, as a reader. bind_outcome hands a continuation the VALUE and +// merges the diagnostics itself, which is right for a pipeline and wrong for a caller that must +// FORWARD them to a producer expecting them as an argument -- v2.compiler.parse parse_module_prepared +// takes the grammar's validation residue that way. Without this, such a caller has to destructure the +// outcome itself, which re-spells the arms bind_outcome already owns. +// +// A REJECTED OUTCOME'S DIAGNOSTICS ARE ITS NON-EMPTY ONES, returned as the same Diagnostics the +// accepted arm answers with, so a consumer forwarding them does not have to know which arm produced +// them. +fn outcome_diagnostics(o: Outcome) -> Diagnostics { + match o { + Accepted { value: _, diagnostics: d } => d + Rejected { diagnostics: r } => Some { diagnostics: r } + } +} + fn bind_outcome(o: Outcome, f: fn(T) -> Outcome) -> Outcome { match o { Accepted { value: v, diagnostics: od } => bind_outcome_accepted(od: od, inner: f(v)) diff --git a/src/v2/std/inhabitance.dag b/src/v2/std/inhabitance.dag index 0987c38d8f8..803eee3e364 100644 --- a/src/v2/std/inhabitance.dag +++ b/src/v2/std/inhabitance.dag @@ -109,6 +109,7 @@ type InhabitanceUndecidableReason | UndecidableOptionalCarrier | UndecidableFormalUnresolved | UndecidableArgumentTypeNotDerived + | UndecidableWherePredicateSubjectUnmodelled type InhabitanceVerdict = Inhabits { homomorphism: HomomorphismWitness } @@ -422,6 +423,10 @@ fn undecidable_argument_type_not_derived() -> InhabitanceUndecidableReason { UndecidableArgumentTypeNotDerived } +fn undecidable_where_predicate_subject_unmodelled() -> InhabitanceUndecidableReason { + UndecidableWherePredicateSubjectUnmodelled +} + fn inhabitance_undecidable_reason_symbol( reason: InhabitanceUndecidableReason @@ -431,6 +436,7 @@ fn inhabitance_undecidable_reason_symbol( UndecidableOptionalCarrier => ^inhabitance_undecidable_optional_carrier UndecidableFormalUnresolved => ^inhabitance_undecidable_formal_unresolved UndecidableArgumentTypeNotDerived => ^inhabitance_undecidable_argument_type_not_derived + UndecidableWherePredicateSubjectUnmodelled => ^inhabitance_undecidable_where_predicate_subject_unmodelled } } diff --git a/src/v2/std/node.dag b/src/v2/std/node.dag index 3c489764849..cbab26ba244 100644 --- a/src/v2/std/node.dag +++ b/src/v2/std/node.dag @@ -1268,13 +1268,29 @@ fn classify_arrow_body_form(target: Node) -> ArrowBodyForm { } } +// WHICH BODY FORMS MAY ENTER EVAL, AND IT IS THE ONE AUTHORITY FOR THAT QUESTION. An eval-local exception +// beside it would be a second authority for the same decision, so a form is admitted here or not at all. +// +// ParameterReferenceBody IS ADMITTED, and that is the change gunbc#12766 named as this lane's obligation. It +// refused categorically while NO route evaluated a declared fn body over resolver output, which was honest +// then and became a wall against the capability once the named-call route existed. MEASURED, by locus rather +// than by reason: the three argument-dependent controls in +// v2.test.claim.callexec.declaration_reference_eval refused with +// ^eval_rejected_parameter_reference_unbound anchored at the callee's DECLARING ARROW, not at the parameter +// use -- v2.compiler.eval eval_callee_body_refusal_reason maps this arm to that same symbol, so the reason +// has two producers and the gate was the one firing, before eval_bind_arrow_params ran at all. +// +// ADMITTING THE FORM IS NOT ADMITTING THE ACCESS. A parameter reference whose binding the environment does +// not hold still refuses with the same reason, now raised at the USE by eval_type_node_atom's own arm, where +// it means what it says. What disappears is a refusal based solely on the body's FORM; what remains is the +// refusal based on a missing binding, which is the only one that was ever a fact about the program. fn arrow_body_admits_eval_entry(form: ArrowBodyForm) -> Bool { match form { UnsupportedArrowBody => false DirectAtomBody => true RecordConstructBody => false DeclarationReferenceBody => false - ParameterReferenceBody => false + ParameterReferenceBody => true LexicalReferenceBody => true FunctionValueBody => false ComputationBody { behavior: Value } => false diff --git a/src/v2/std/qualified_name.dag b/src/v2/std/qualified_name.dag index d9c63f7d2f1..de2b4db79bf 100644 --- a/src/v2/std/qualified_name.dag +++ b/src/v2/std/qualified_name.dag @@ -255,6 +255,57 @@ fn qualified_name_spine_shape_present(root: Node) -> Bool { } } +// THE SPINE'S SEGMENTS AS THE NODES THAT CARRY THEM, beside the reader that returns their symbols. +// qualified_name_from_node answers the NAME -- a FreeMonoid -- which is everything a consumer +// deciding what a name denotes needs. A consumer REWRITING a spine into another construct needs the +// segment NODES instead, because each segment was lowered from its own token and carries that token's +// occurrence (v2.compiler.body_lowering_fold builds the spine from exactly those atoms). Rebuilding a +// segment from its symbol would mint a synthetic node in its place and move the diagnostic locus of +// every error about that segment onto the whole chain. +// +// IT LIVES HERE BECAUSE THE LABEL SET DOES. This module holds the spine's reader and its inverse +// together so that no producer spells the spine with labels the reader does not accept; a segment-node +// walker written anywhere else would be a third surface over fold_list_node's labels, read through +// qn_spine_role or -- worse -- re-spelled. The shape gate is the same one qualified_name_from_node +// uses, so the two readers accept exactly the same spines and a consumer cannot get segments for a +// node the name reader would refuse. +fn qn_spine_role_node_optional(root: Node, wanted_head: Bool) -> Optional { + fold(root.children, init: optional_absent(), f: fn(acc, edge) { + match qn_spine_role(label: edge.label) { + QnSpineHead => if wanted_head { optional_present(value: edge.target) } else { acc } + QnSpineTail => if wanted_head { acc } else { optional_present(value: edge.target) } + QnNotSpine => acc + } + }) +} + +fn qualified_name_spine_segment_nodes(root: Node) -> Optional> { + match root.kind { + TypeNode { connective: Conj } => + if count(root.children) == 0 { + optional_present(value: Empty) + } else if qualified_name_spine_shape_present(root: root) { + match qn_spine_role_node_optional(root: root, wanted_head: true) { + Absent => optional_absent() + Present { value: segment } => + match qn_spine_role_node_optional(root: root, wanted_head: false) { + Absent => optional_absent() + Present { value: tail } => + match qualified_name_spine_segment_nodes(root: tail) { + Absent => optional_absent() + Present { value: rest } => + optional_present(value: Cons { head: segment, tail: rest }) + } + } + } + } else { + optional_absent() + } + TypeNode { connective: _ } => optional_absent() + ComputationNode { behavior: _ } => optional_absent() + } +} + fn qualified_name_from_node(root: Node) -> Outcome { match root.kind { TypeNode { connective: Conj } => diff --git a/src/v2/std/runtime.dag b/src/v2/std/runtime.dag index f82ac4913b5..118ba95f726 100644 --- a/src/v2/std/runtime.dag +++ b/src/v2/std/runtime.dag @@ -5,6 +5,7 @@ import v2.std.collection { Map } import v2.std.optional { + Absent, Optional, Present, optional_absent, @@ -31,7 +32,8 @@ import v2.std.node { Node, Symbol, TypeNode, - node_synthetic + node_synthetic, + symbol_eq } type RuntimeIdentity { @@ -204,6 +206,41 @@ fn runtime_field_value_node_projection( } } +// THE VALUE-SIDE TWIN OF v2.std.node_query declared_field_named, and it lives here because the field +// list is this module's carrier. A consumer selecting a field -- the evaluator's field projection is the +// first -- reads it through this one reader rather than destructuring the list itself, so there is one +// answer to "which value does this aggregate hold for this field". +type RuntimeFieldSelection + = RuntimeFieldFound { value: RuntimeValue } + | RuntimeFieldMissing + +fn runtime_field_value_matching( + field_value: RuntimeFieldValue, + name: Symbol +) -> RuntimeFieldSelection { + if symbol_eq(a: field_value.field, b: name) { + RuntimeFieldFound { value: field_value.value } + } else { + RuntimeFieldMissing + } +} + +fn runtime_aggregate_field_selection( + fields: List, + name: Symbol +) -> RuntimeFieldSelection { + fold_list( + xs: fields, + empty: RuntimeFieldMissing, + cons: fn(acc, field_value) { + match runtime_field_value_matching(field_value: field_value, name: name) { + RuntimeFieldFound { value: v } => RuntimeFieldFound { value: v } + RuntimeFieldMissing => acc + } + } + ) +} + fn runtime_fields_list_node(fields: List) -> RuntimeValueNodeProjection { fold_list( xs: fields, diff --git a/src/v2/test/claim/binder_admission/callable_binder_slice_test.dag b/src/v2/test/claim/binder_admission/callable_binder_slice_test.dag new file mode 100644 index 00000000000..871067bc9e1 --- /dev/null +++ b/src/v2/test/claim/binder_admission/callable_binder_slice_test.dag @@ -0,0 +1,306 @@ +module v2.test.claim.binder_admission.callable_binder_slice + +import v2.compiler.resolve { + LexicalBinding, + frame_binders_add, + frame_binders_empty, + lexical_frame, + NotDeclaring, + BinderAdmission, + BinderDuplicateInFrame, + BinderHidesVisibleValue, + BindersAdmitted, + Namespace, + Scope, + ScopeFrame, + ScopeRoot, + arrow_domain_frame_binders, + admit_value_binders, + empty_canonical_symbol_set, + empty_namespace, + ResolveContext, + ResolveNodeWalk, + ResolveWalkAccepted, + ResolveWalkRefused, + resolve_node_walk, +} +import v2.std.resolution_policy { default_name_resolution_policy } +import v2.compiler.fold_lowering { fold_carrier_binder_atom } +import v2.std.collection { Map, empty_map, map_insert, map_lookup } +import std.algebra { Cons, Empty } +import v2.std.language_model { void_language_model } +import v2.std.text { String } +import v2.std.symbol_index { empty_symbol_index } +import v2.std.node { + Arrow, + Loop, + Atom, + ComputationNode, + Conj, + Edge, + Authored, + LoopCarrierEdge, + core_edge_label, + Node, + Positional, + TypeNode, + +} +import v2.std.optional { Absent, Present, optional_absent } +import v2.std.logic { Bool } +import std.occurrence_identity { OccurrenceId, OccurrenceMinted, OccurrenceSynthetic } + +// THE CALLABLE-BINDER SLICE, READ AT ONE INTERFACE EACH, WITH SUPPLIED NODES. Every claim here +// constructs the node it asks about. None assembles source, resolves a corpus or runs infer: the +// subject is what a binder-harvesting function answers for a node of a given shape, so executing the +// front end to obtain that node would re-run production the claim is not about (DESIGN section 3 +// witness rule) -- and it is exactly the reach whose cost refused this lane's other claim sets at the +// enrolment margin. +// +// WHAT THE SLICE ESTABLISHES. `fold(xs, init: false, f: fn(found, e) { found || ... })` refuses at +// resolve with resolve_unbound_name_is_declared_in_several_modules on `found`. The rows below locate +// that in the lowering rather than in resolve's binder model: the Arrow path admits its binders, and +// the fold seam the step is lowered to carries none. + +// THE LOOP THESE ROWS SUPPLY MIRRORS THE FOLD ENCODING'S, and it is built here rather than by calling the +// producer because gunbc#12550 retired the old single-purpose seam builder: the encoding is now a Bind +// whose body is this Loop (v2.compiler.fold_lowering fold_recurrence_encoding), and calling THAT to obtain +// a Loop would drag a collection, an init and a realized head into a row whose subject is one edge. The +// carrier atom still comes from the production constructor (fold_carrier_binder_atom), so a change to how +// a carrier binder is spelled reaches these rows. +fn cbs_carrier_loop(body: Node, carrier: Symbol) -> Node { + Node { + kind: ComputationNode { behavior: Loop }, + children: [ + Edge { label: Positional, target: body }, + Edge { label: core_edge_label(marker: LoopCarrierEdge), target: fold_carrier_binder_atom(binder: carrier) } + ], + occurrence_id: OccurrenceSynthetic + } +} + +fn cbs_atom(id: Symbol) -> Node { + Node { kind: TypeNode { connective: Atom { identity: id } }, children: [], occurrence_id: OccurrenceSynthetic } +} + +// An Arrow shaped the way a fn literal's is (v2.compiler.body_lowering_fold +// body_lower_function_value_arrow): a domain Conj of Named binders, then the codomain twice. +fn cbs_fn_literal_shaped_arrow() -> Node { + Node { + kind: TypeNode { connective: Arrow }, + children: [ + Edge { + label: Positional, + target: Node { + kind: TypeNode { connective: Conj }, + children: [ + Edge { label: Authored { name: ^found }, target: cbs_atom(id: ^tv_found) }, + Edge { label: Authored { name: ^e }, target: cbs_atom(id: ^tv_e) } + ], + occurrence_id: OccurrenceSynthetic + } + }, + Edge { label: Positional, target: cbs_atom(id: ^tv_ret) }, + Edge { label: Positional, target: cbs_atom(id: ^tv_ret) } + ], + occurrence_id: OccurrenceSynthetic + } +} + +fn cbs_admits(locals: Map, name: Symbol) -> Bool { + match map_lookup(m: locals, key: name) { + Present { value: _ } => true + Absent => false + } +} + +fn cbs_arrow_locals() -> Map { + arrow_domain_frame_binders(n: cbs_fn_literal_shaped_arrow()).locals +} + +// (1) THE ARROW PATH ADMITS BOTH BINDERS. So resolve's binder model is not the defect: a callable +// whose domain carries Named binders opens a frame holding every one of them. +test fn cbs_the_arrow_path_admits_both_binders_holds() -> Bool { + cbs_admits(locals: cbs_arrow_locals(), name: ^found) + && cbs_admits(locals: cbs_arrow_locals(), name: ^e) +} + +// (2) AND IT ADMITS ONLY WHAT THE DOMAIN DECLARES, so row (1) is not a function that answers true for +// every name. +test fn cbs_the_arrow_path_admits_no_undeclared_name_holds() -> Bool { + !cbs_admits(locals: cbs_arrow_locals(), name: ^unrelated) +} + +// (3) THE FOLD SEAM ADMITS NEITHER BINDER -- THE DISCRIMINATING RED FOR THIS SLICE. The step literal +// is destructured into an iteration body and ONE carrier symbol (v2.compiler.fold_lowering +// fold_call_seam_from_step), and the seam it builds is a Loop, not an Arrow. arrow_domain_frame_binders +// answers the empty map for it because it is not an Arrow at all, and resolve harvests binders in +// exactly two places -- an Arrow's domain and a Bind's atom -- with NO loop-carrier harvester. So the +// body's `found` reaches the bare-name census and refuses as declared in several modules. +// +// THIS ROW FLIPS WHEN THE CONSOLIDATION LANDS: once the step lowers through the one callable Arrow +// constructor and loop carriers go through the one binder-admission operation, the seam admits its +// binders and this row must be rewritten to assert that, not deleted (DESIGN section 4b(4)). +test fn cbs_the_fold_seam_admits_no_binder_today_holds() -> Bool { + let seam_locals = arrow_domain_frame_binders( + n: cbs_carrier_loop(body: cbs_atom(id: ^step_body), carrier: ^found) + ).locals + !cbs_admits(locals: seam_locals, name: ^found) + && !cbs_admits(locals: seam_locals, name: ^e) +} + +// THE ADMISSION'S THREE RULES, each at its own interface with a supplied Scope. The scopes are +// constructed, not obtained by resolving a corpus: the subject is what admit_value_binders answers for +// a (names, scope) pair. +fn cbs_root_scope() -> Scope { + ScopeRoot { module: empty_namespace() } +} + +fn cbs_root_scope_declaring(name: Symbol) -> Scope { + ScopeRoot { + module: Namespace { + bindings: map_insert(empty_map(), name, name), + canonical_symbols: empty_canonical_symbol_set(), + symbol_index: empty_symbol_index(), + module_qn: Empty, + test_code: test_code_index_empty(), + declared_in: Empty, + imported_origins: empty_map() + } + } +} + +// A Lexical frame as resolve builds one (v2.compiler.resolve lexical_frame over frame_binders_add): the name +// is both a local and a recorded binding, so a use it answers is minted as a lexical reference rather than +// refused as unrecorded. +fn cbs_frame_binding(name: Symbol) -> Scope { + lexical_frame( + fb: frame_binders_add( + fb: frame_binders_empty(), + name: name, + binding: LexicalBinding { binder_site: OccurrenceMinted { id: OccurrenceId { value: 1 } }, declared: optional_absent() } + ), + outer: cbs_root_scope() + ) +} + +// A use with a MINTED occurrence: a lexical reference is keyed by its own occurrence, and a synthetic one +// refuses as unkeyed (resolve_lexical_reference), which would answer for a different reason than row (9)'s. +fn cbs_minted_atom(id: Symbol) -> Node { + Node { kind: TypeNode { connective: Atom { identity: id } }, children: [], occurrence_id: OccurrenceMinted { id: OccurrenceId { value: 2 } } } +} + +fn cbs_admitted_has(a: BinderAdmission, name: Symbol) -> Bool { + match a { + BindersAdmitted { locals: m } => cbs_admits(locals: m, name: name) + BinderDuplicateInFrame { binder: _ } => false + BinderHidesVisibleValue { binder: _ } => false + } +} + +// (4) A CARRIER IS ADMITTED. The positive control for the Loop repair: the name a fold seam carries +// opens a frame instead of falling through to the global lookup. +test fn cbs_a_loop_carrier_name_is_admitted_holds() -> Bool { + cbs_admitted_has(a: admit_value_binders(names: [^found], outer: cbs_root_scope()), name: ^found) +} + +// (5) SAME-FRAME DUPLICATES REFUSE. +test fn cbs_a_same_frame_duplicate_refuses_holds() -> Bool { + match admit_value_binders(names: [^found, ^found], outer: cbs_root_scope()) { + BinderDuplicateInFrame { binder: b } => b == ^found + BindersAdmitted { locals: _ } => false + BinderHidesVisibleValue { binder: _ } => false + } +} + +// (6) A BINDER HIDING A VISIBLE VALUE BINDING REFUSES. `found` is already bound by an enclosing FRAME. +test fn cbs_a_binder_hiding_an_enclosing_binder_refuses_holds() -> Bool { + match admit_value_binders(names: [^found], outer: cbs_frame_binding(name: ^found)) { + BinderHidesVisibleValue { binder: b } => b == ^found + BindersAdmitted { locals: _ } => false + BinderDuplicateInFrame { binder: _ } => false + } +} + +// (7) AND A NAME DECLARED AT THE MODULE ROOT IS STILL ADMITTED -- the clause that keeps unrelated +// declarations elsewhere in the corpus from deciding whether a local binder is legal. This is the +// discriminating negative for row (6): if the admission consulted the namespace rather than the lexical +// frames, (6) and (7) would both refuse and the pair would establish nothing. +test fn cbs_a_binder_spelled_like_a_root_declaration_is_admitted_holds() -> Bool { + cbs_admitted_has( + a: admit_value_binders(names: [^found], outer: cbs_root_scope_declaring(name: ^found)), + name: ^found + ) +} + +// THE PAIRING OBLIGATION FOR THIS SLICE IS THE NATIVE EIGHT, NOT A SOURCE FIXTURE HERE, and that is a +// measured conclusion rather than a preference. A fold cannot be written in a bare single-module fixture +// at all: `List`, a list literal and `fold` itself are each unbound without imports, and DECLARING an +// import turns the file's bare-reference channel off (gunbc.recurring_failure_mode +// bare_reference_channel_declines_a_pull_in_silence), which changes the very name resolution under test. +// Three fixtures were tried and all three refused before reaching any binder. +// +// So the claim that the real producer emits this shape is +// `//v2/test/parse/expression_bodied_fn_decl_parse:all` run natively -- the eight whose refusal opened +// this slice, whose own `fold(root.children, init: false, f: fn(found, e) { found || ... })` is the +// producer in question. Until that runs green the rows above are readings of a boundary, and this +// comment is where that debt is recorded rather than in a green row that would imply otherwise. + +// (8) AND A LOOP DOES NOT BIND ITS CARRIER -- THE ENCLOSING Bind DOES. These two rows asserted the +// opposite until gunbc#12550 landed, and the reversal is the honest record of a repair that dissolved +// rather than a test that was wrong. The pre-#12550 fold seam built a BARE Loop with no enclosing Bind, so +// its carrier was bound by nothing and a step body's use of it reached the bare-name census; a frame +// opened at the Loop repaired that, and these rows were its evidence. #12550 replaced the seam with +// Bind { carrier := init, Loop { ... } }, where v2.std.node's role model always said the binder lived -- +// "the carried state's initial value lives on an enclosing Bind" -- and the frame became redundant AND +// harmful, because the Loop re-admitted a name the Bind had bound and this file's own admission refused it +// as hiding a visible binding. +// +// So the rows now assert the model that is true: a BARE Loop's carrier binds nothing, and a body that uses +// it is unbound. A reader who expects the old behaviour is reading evidence for a seam that no longer +// exists (DESIGN section 4b(4): the production handling goes, the evidence stays and flips). +fn cbs_ctx(scope: Scope) -> ResolveContext { + ResolveContext { + scope: scope, + type_scope: scope, + namespace: empty_namespace(), + policy: default_name_resolution_policy(), + position: Empty, + lm: void_language_model(identity: ^cbs_void_language), + under_module_root: false, + declaring: NotDeclaring + } +} + +fn cbs_walk_accepts(n: Node) -> Bool { + match resolve_node_walk(ctx: cbs_ctx(scope: cbs_root_scope()), n: n) { + ResolveWalkAccepted { value: _, diagnostics: _, lexical: _ } => true + ResolveWalkRefused { first: _, rest: _, observation: _ } => false + } +} + +test fn cbs_a_bare_loop_does_not_bind_its_carrier_holds() -> Bool { + !cbs_walk_accepts(n: cbs_carrier_loop(body: cbs_atom(id: ^found), carrier: ^found)) +} + +// NEITHER ROW CARRIES A BOUND MEASURE, AND THAT IS NOT TIDYING -- IT CLOSES A VACUITY. The measure the +// fold encoding uses is ^dag_surface_fold_iteration_measure, a symbol the CORPUS declares (it is +// registered in v2.std.cardinality measure_descent_fact_registry); the supplied namespace these rows walk +// under is empty, so it cannot bind that symbol and a Loop carrying it refuses for THAT reason. Row (8) +// passed with a measure present, which means it would have passed even if the carrier HAD been bound -- +// the measure was answering for it. Measured: the measure atom alone refuses under this context, and the +// same carrier Loop without it resolves once the name is bound above. + +// (9) AND THE SAME BARE LOOP UNDER A SCOPE THAT DOES BIND THE NAME RESOLVES -- so row (8) reports "the +// Loop opens no frame" rather than "this Loop never resolves". That is the discriminating pair: the only +// variable between them is whether something ABOVE the Loop binds the carrier, which is exactly what the +// fold encoding's Bind now does. +test fn cbs_a_loop_carrier_bound_above_resolves_holds() -> Bool { + match resolve_node_walk( + ctx: cbs_ctx(scope: cbs_frame_binding(name: ^found)), + n: cbs_carrier_loop(body: cbs_minted_atom(id: ^found), carrier: ^found) + ) { + ResolveWalkAccepted { value: _, diagnostics: _, lexical: _ } => true + ResolveWalkRefused { first: _, rest: _, observation: _ } => false + } +} diff --git a/src/v2/test/claim/callexec/declaration_reference_eval_test.dag b/src/v2/test/claim/callexec/declaration_reference_eval_test.dag new file mode 100644 index 00000000000..c8b57f3c6ed --- /dev/null +++ b/src/v2/test/claim/callexec/declaration_reference_eval_test.dag @@ -0,0 +1,654 @@ +module v2.test.claim.callexec.declaration_reference_eval + +import v2.compiler.resolve { ResolvedTree } +import v2.compiler.infer { infer } +import v2.compiler.inferred_tree { InferredTree } +import v2.compiler.eval { + arrow_body_admits_eval_entry, + empty_evaluation_environment, + eval_parameter_reference_value, + eval, + eval_callee_declaration_optional, + inputs_root_only +} +import v2.extdeps.runtimes.v2_evaluator { v2_evaluator_interpretation } +import v2.compiler.name_resolve { Admission, ResolutionSubject } +import v2.compiler.program_assembly { assemble_program_from_ingest } +import v2.compiler.source_authority { DagSourceReadWitness } +import v2.extdeps.languages.dag { dag_language_model } +import extdeps.communication.medium { Lossless, Medium } +import std.algebra { Cons, Empty } +import v2.std.cross_tree.import_model { V2Tree } +import v2.std.artifact { Artifact, SourceFile } +import v2.std.diagnostic { + Accepted, + Diagnostic, + NodeLocus, + Outcome, + Rejected +} +import v2.std.logic { Bool } +import v2.std.node { + ParameterReferenceBody, + DeclarationReferenceKind, + Symbol, + symbol_eq, + Conj, + Edge, + Node, + NodeFold, + TypeNode, + ComputationNode, + Transform, + fold_node +} +import v2.std.node_query { node_positional_child_targets } +import v2.std.optional { Absent, Optional, Present, optional_absent, optional_present } +import v2.std.collection { List, list_at_optional } +import v2.std.integer { Int, integer_signed_i32_le_bytes_to_int } + +import v2.std.runtime { RuntimePrimitive, RuntimeValue } +import v2.std.algebra { fold_list } +import v2.std.qualified_name { + parameter_reference_node, qualified_name_last_segment, + declaration_reference_path_optional, + resolved_reference_spine_optional +} + +// THE EVALUATOR BOUNDARY FOR A NAMED CALL, ISOLATED FROM THE NATIVE ROUTE. The reference-evidence +// repair made a named call GROUND under infer; it did not make one EXECUTE. The native qualification +// (v2.test.callexec.named_call_execution) refused at eval with +// eval_rejected_grounding_not_derived anchored on a node the renderer prints as a synthetic +// occurrence, and a synthetic occurrence is a PROVENANCE CATEGORY rather than an identity -- so the +// native log alone cannot say WHICH node. This file supplies the same call shape at the eval +// boundary and reads the anchor directly, which is the one comparison that decides it. +// +// WHAT THE SOURCE ALREADY ESTABLISHES, so the claims below only have to confirm it. eval's callee +// classifier (v2.compiler.eval eval_node_is_callee_reference) admits an Arrow and a bare Atom and +// nothing else, while a resolved reference is a MARKED CONJ (v2.compiler.resolve +// resolved_reference_node -> v2.std.qualified_name declaration_reference_node). So the callee edge is +// not recognized as a callee reference, eval_fold_child_for_edge takes its ordinary recursive arm, +// and the walk descends into the reference's encoded declaring path -- whose spine +// declaration_reference_node deliberately builds at OccurrenceSynthetic, because the authored +// occurrence belongs to the marker node that stands where the reference stood. Those spine nodes are +// visited by infer too, so each holds an entry with grounding UNDERIVED rather than no entry at all, +// which is why the gate reports grounding_not_derived and not a facts lookup miss. +data cref_artifact: Artifact = Artifact { + kind: SourceFile, + id: ^declaration_reference_eval_artifact, + file_path: "src/v2/pilot/declaration_reference_eval_pilot.dag" +} + +fn cref_assemble(src: String) -> Outcome { + assemble_program_from_ingest( + ingest: Cons { + head: DagSourceReadWitness { + source: Medium { carried: src, fidelity: Lossless }, + artifact: cref_artifact, + compilation_unit: ^declaration_reference_eval_cu, + source_root: V2Tree + }, + tail: Empty + }, + admission: Admission { subject: ResolutionSubject { name: Cons { head: ^p, tail: Empty } }, imports: Empty }, + lm: dag_language_model() + ) +} + +// A LITERAL ARGUMENT, so the call is evaluable on its own: a parameter use would be unbound in the +// empty environment and would refuse for a reason that is not this subject. +fn cref_source() -> Outcome { + cref_assemble(src: "module p\n\nfn callee(only_arg: Int) -> Int {\n only_arg\n}\n\nfn consumer() -> Int {\n callee(only_arg: 3)\n}\n") +} + +fn cref_inferred() -> Optional { + match cref_source() { + Rejected { diagnostics: _ } => optional_absent() + Accepted { value: resolved, diagnostics: _ } => + match infer(tree: resolved) { + Rejected { diagnostics: _ } => optional_absent() + Accepted { value: inferred, diagnostics: _ } => optional_present(value: inferred) + } + } +} + +// THE CALL IS SELECTED BY ITS CALLEE'S FULL DECLARING PATH, THROUGH THE PRODUCTION READER. What stood +// here decoded the call itself: it matched ComputationNode { behavior: Transform } and read positional +// child 0 by hand. That is a second reader of the application encoding, and it decayed exactly as DESIGN +// section 3 says a positional scheme does -- main moved the call's lowered shape and this file's locator +// answered Absent for every fixture, so nine rows went red while infer ACCEPTED the same trees and eval +// was never reached. The rows were not wrong; they could not find their subject. +// +// It now asks v2.compiler.infer infer_application_callee_use, the same reader the application's own +// formals and type parameters come from, so a change to the encoding reaches this file the way it reaches +// infer. +// +// AND IT SELECTS BY THE WHOLE PATH, NOT BY SHAPE OR BY A LEAF. The old annotation claimed it keyed on "a +// declaration path ending in `callee`"; the code checked no name at all and took the FIRST node with any +// declaration-reference callee, so an annotation or an unrelated reference could have been selected and +// the claim would still have looked green. Each fixture names one callee at module `p`, and the locator +// requires EXACTLY ONE call to that full path -- two matches are Absent, so a fixture that grows a second +// call to the same declaration refuses rather than silently picking one. +fn cref_callee_path_is(callee: Node, name: Symbol) -> Bool { + match declaration_reference_path_optional(node: callee) { + Absent => false + Present { value: path } => path == Cons { head: ^p, tail: Cons { head: name, tail: Empty } } + } +} + +fn cref_calls_to(root: Node, name: Symbol) -> List { + fold_node( + n: root, + algebra: NodeFold { + init: fn(n0) { + match infer_application_callee_use(node: n0) { + Absent => [] + Present { value: callee } => + if cref_callee_path_is(callee: callee, name: name) { [n0] } else { [] } + } + }, + step: fn(acc, _e, child) { concat(acc, child) } + } + ) +} + +fn cref_the_call_to(root: Node, name: Symbol) -> Optional { + match cref_calls_to(root: root, name: name) { + Cons { head: call, tail: rest } => + match rest { + Empty => optional_present(value: call) + Cons { head: _, tail: _ } => optional_absent() + } + Empty => optional_absent() + } +} + +fn cref_callee_reference_optional(n: Node) -> Optional { + match infer_application_callee_use(node: n) { + Absent => optional_absent() + Present { value: callee } => + match declaration_reference_path_optional(node: callee) { + Absent => optional_absent() + Present { value: _ } => optional_present(value: callee) + } + } +} + +// EVERY NODE STRICTLY UNDER THE REFERENCE MARKER -- the encoded declaring path and nothing else. The +// marker itself is excluded, because the marker IS the node that stands where the reference stood and +// a refusal anchored there would be a different finding from a refusal anchored on path data. +fn cref_spine_nodes(reference: Node) -> List { + match resolved_reference_spine_optional(node: reference, kind: DeclarationReferenceKind) { + Absent => Empty + Present { value: spine } => + fold_node( + n: spine, + algebra: NodeFold { + init: fn(n0) { Cons { head: n0, tail: Empty } }, + step: fn(acc, _e, child) { list_append_nodes(left: acc, right: child) } + } + ) + } +} + +fn list_append_nodes(left: List, right: List) -> List { + fold_list( + xs: left, + empty: right, + cons: fn(rest, item) { Cons { head: item, tail: rest } } + ) +} + +fn cref_node_in(xs: List, wanted: Node) -> Bool { + fold_list( + xs: xs, + empty: false, + cons: fn(rest, item) { if item == wanted { true } else { rest } } + ) +} + +fn cref_diagnostic_node_optional(d: Diagnostic) -> Optional { + match d.at { + NodeLocus { anchor: a } => optional_present(value: a.at) + _ => optional_absent() + } +} + +fn cref_eval_of_the_call() -> Optional> { + match cref_inferred() { + Absent => optional_absent() + Present { value: inferred } => + match cref_the_call_to(root: inferred.root, name: ^callee) { + Absent => optional_absent() + Present { value: call } => + optional_present( + value: eval( + tree: inferred, + interpretation: v2_evaluator_interpretation(), + inputs: inputs_root_only(root: call) + ) + ) + } + } +} + +fn cref_zero_arg_source() -> Outcome { + cref_assemble(src: "module p\n\nfn callee() -> Int {\n 7\n}\n\nfn consumer() -> Int {\n callee()\n}\n") +} + +fn cref_one_arg_constant_body_source() -> Outcome { + cref_assemble(src: "module p\n\nfn callee(only_arg: Int) -> Int {\n 7\n}\n\nfn consumer() -> Int {\n callee(only_arg: 3)\n}\n") +} + +fn cref_bool_pair_source() -> Outcome { + cref_assemble(src: "module p\n\nfn truth(only_arg: Int) -> Bool {\n true\n}\n\nfn consumer() -> Bool {\n truth(only_arg: 3)\n}\n") +} + +fn cref_unresolved_callee_source() -> Outcome { + cref_assemble(src: "module p\n\nfn consumer() -> Int {\n absent_callee(only_arg: 3)\n}\n") +} + +fn cref_inferred_of(o: Outcome) -> Optional { + match o { + Rejected { diagnostics: _ } => optional_absent() + Accepted { value: resolved, diagnostics: _ } => + match infer(tree: resolved) { + Rejected { diagnostics: _ } => optional_absent() + Accepted { value: inferred, diagnostics: _ } => optional_present(value: inferred) + } + } +} + +fn cref_eval_of(o: Outcome) -> Optional> { + cref_eval_of_call_to(o: o, name: ^callee) +} + +// THE CALLEE'S NAME IS A PARAMETER BECAUSE THE SELECTION IS BY FULL PATH. Each fixture declares one +// callee at module `p` and the locator requires exactly one call to it, so a helper that guessed the name +// would be back to selecting by shape. +fn cref_eval_of_call_to(o: Outcome, name: Symbol) -> Optional> { + match cref_inferred_of(o: o) { + Absent => optional_absent() + Present { value: inferred } => + match cref_the_call_to(root: inferred.root, name: name) { + Absent => optional_absent() + Present { value: call } => + optional_present( + value: eval( + tree: inferred, + interpretation: v2_evaluator_interpretation(), + inputs: inputs_root_only(root: call) + ) + ) + } + } +} + +// ONE FRONT END AND ONE EVAL PER FIXTURE, READ BY EVERY ROW THAT ASKS IT. Each fixture's assemble -> +// resolve -> infer -> eval runs ONCE, inside a nullary producer the floor serves warm +// (v2.workflow.floor_pure_producer_share), and the rows read the decided, portable values: whether the +// call to the named callee was found, whether its reference facts carry their declaration, whether it +// executed, and its result as an Int and as primitive bytes. Nothing is supplied -- every producer runs +// the real path -- so these rows remain that path's execution and keep the reds they had; the readers +// below are the old per-row ones, now asked once of one eval outcome. +type CrefReading { + call_found: Bool + declaration_carried: Bool + executed: Bool + int_result: Optional + result_bytes: Optional> +} + +fn cref_reading(o: Outcome, name: Symbol) -> CrefReading { + let unfound = CrefReading { call_found: false, declaration_carried: false, executed: false, int_result: optional_absent(), result_bytes: optional_absent() } + match cref_inferred_of(o: o) { + Absent => unfound + Present { value: inferred } => + match cref_the_call_to(root: inferred.root, name: name) { + Absent => unfound + Present { value: call } => + cref_reading_of_outcome( + declaration_carried: cref_call_carries_declaration(inferred: inferred, call: call), + outcome: eval( + tree: inferred, + interpretation: v2_evaluator_interpretation(), + inputs: inputs_root_only(root: call) + ) + ) + } + } +} + +fn cref_call_carries_declaration(inferred: InferredTree, call: Node) -> Bool { + match cref_callee_reference_optional(n: call) { + Absent => false + Present { value: reference } => + match eval_callee_declaration_optional(tree: inferred, callee: reference) { + Present { value: _ } => true + Absent => false + } + } +} + +fn cref_reading_of_outcome(declaration_carried: Bool, outcome: Outcome) -> CrefReading { + match outcome { + Rejected { diagnostics: _ } => + CrefReading { call_found: true, declaration_carried: declaration_carried, executed: false, int_result: optional_absent(), result_bytes: optional_absent() } + Accepted { value: v, diagnostics: _ } => + CrefReading { call_found: true, declaration_carried: declaration_carried, executed: true, int_result: cref_value_int_optional(v: v), result_bytes: cref_value_bytes(v: v) } + } +} + +fn cref_source_reading() -> CrefReading { + cref_reading(o: cref_source(), name: ^callee) +} + +fn cref_zero_arg_reading() -> CrefReading { + cref_reading(o: cref_zero_arg_source(), name: ^callee) +} + +fn cref_one_arg_constant_body_reading() -> CrefReading { + cref_reading(o: cref_one_arg_constant_body_source(), name: ^callee) +} + +fn cref_bool_pair_reading() -> CrefReading { + cref_reading(o: cref_bool_pair_source(), name: ^truth) +} + +fn cref_bool_false_reading() -> CrefReading { + cref_reading(o: cref_bool_false_source(), name: ^falsity) +} + +fn cref_unresolved_callee_reading() -> CrefReading { + cref_reading(o: cref_unresolved_callee_source(), name: ^callee) +} + +fn cref_identity_three_reading() -> CrefReading { + cref_reading(o: cref_identity_source(lex: "3"), name: ^identity) +} + +fn cref_identity_eight_reading() -> CrefReading { + cref_reading(o: cref_identity_source(lex: "8"), name: ^identity) +} + +// ONE READER FOR "WHAT INT DID THIS CALL PRODUCE". cref_executes_to asks it a question and the exact +// rows below ask it for the magnitude, so a second decoder beside it would be one fact with two +// authorities (DESIGN section 3) -- and the decoding rule, that only a four-byte signed primitive +// counts, is exactly the sort that drifts between two copies. +fn cref_executes_to(r: CrefReading, n: Int) -> Bool { + match r.int_result { + Absent => false + Present { value: magnitude } => magnitude == n + } +} + +fn cref_refusal_reason_is(o: Outcome, wanted: Symbol, name: Symbol) -> Bool { + match cref_eval_of_call_to(o: o, name: name) { + Absent => false + Present { value: outcome } => + match outcome { + Accepted { value: _, diagnostics: _ } => false + Rejected { diagnostics: ds } => symbol_eq(a: ds.head.reason, b: wanted) + } + } +} + +// THE FIXTURE'S OWN POSITIVE CONTROL, so a red below is a statement about eval and not about an +// assembly that stopped producing a call. Without it every claim here could pass vacuously. +test fn cref_the_fixture_yields_a_reference_callee_call_holds() -> Bool { + cref_source_reading().call_found +} + +// THE DENOTATION REACHES EVAL. Infer records the declaration the guarded index lookup returned, and +// eval's reader finds it. This is the seam the whole repair rests on, so it is asserted directly and +// not only through the execution claims: if this goes red, the reds below are explained by a missing +// denotation rather than by the dispatch. +test fn cref_the_reference_facts_carry_their_declaration_holds() -> Bool { + cref_source_reading().declaration_carried +} + +// A NAMED CALL EXECUTES, AND EXECUTES TO ITS CALLEE'S VALUE. The value is asserted rather than mere +// acceptance: any Int-returning path would satisfy "Accepted" while proving nothing about WHICH +// declaration ran, and 7 is written only in the callee's body. +test fn cref_a_zero_argument_named_call_executes_to_its_callee_value_holds() -> Bool { + cref_executes_to(r: cref_zero_arg_reading(), n: 7) +} + +// THE SAME WITH AN ARGUMENT SUPPLIED, which is a separate fact from the zero-argument case because +// the argument edge is a separate child of the call. +test fn cref_a_named_call_with_an_argument_executes_holds() -> Bool { + cref_executes_to(r: cref_one_arg_constant_body_reading(), n: 7) +} + +// PARAMETER BINDING IS NOT YET DEMONSTRATED, AND THIS IS THE CLAIM THAT WOULD DEMONSTRATE IT. Both +// executing claims above have CONSTANT bodies, so neither proves the supplied argument reaches the +// callee's parameter -- a callee ignoring its argument entirely would pass both. This fixture returns +// its parameter, so its value depends on the argument, and it REFUSES today. Enrolled executed and +// expected-red rather than deleted, so the gap is counted rather than described. +// FLIPPED, AND THE ROW STAYS ENROLLED AS A REGRESSION CONTROL (DESIGN section 4b(4): a climb deletes the +// lower-rung production handling, never the evidence). The refusal this row was written to count is +// gone: the supplied argument reaches the callee's parameter and the call executes. The wall that +// landed it is the one reader for a callee's arrow -- v2.compiler.infer +// infer_application_formal_args now reads it through the callee's FACTS, as its caller's formals +// already did, and infer_declaration_reference_facts reads the declaration from +// v2.compiler.resolve ResolvedTree resolved_declarations rather than the authored symbol_index. +test fn cref_a_parameter_bodied_callee_executes_holds() -> Bool { + cref_source_reading().executed +} + +// A BOOL-RETURNING CALL STILL REFUSES, AND IT IS A DIFFERENT BOUNDARY FROM THE PARAMETER BODY. This +// callee's body is a constant, so it differs from the executing claim above only in its RETURN TYPE. +// The call grounds under infer, so this sits downstream of both the reference repair and the dispatch +// repair. Enrolled executed, not diagnosed further here. +// FLIPPED. The return type was never the variable: the callee's declared return `Bool` read as the +// authored spelling, which the binding-to-type table does not denote, so the application's result +// typing fell to the frontier. Reading the RESOLVED declaration answers it, and the same repair is why +// the Int rows above never separated from this one on their own. +test fn cref_a_bool_returning_call_executes_holds() -> Bool { + cref_bool_pair_reading().executed +} + +// THE DISCRIMINATING NEGATIVE FOR THE DISPATCH: a callee naming no declaration must refuse rather +// than execute, fabricate a value, or be looked up as a primitive under a name it does not have. +// Without this, the repair could have admitted any Conj as a callee and still looked green. +test fn cref_an_unresolved_callee_does_not_execute_holds() -> Bool { + !cref_unresolved_callee_reading().executed +} + +// AN ARGUMENT-DEPENDENT CALLEE: its body IS its parameter, so its result cannot be produced without +// consuming the supplied argument. Two different arguments are supplied because ONE would not +// discriminate -- a callee returning a constant that happened to equal the argument would satisfy a +// single case. +fn cref_identity_source(lex: String) -> Outcome { + cref_assemble(src: "module p\n\nfn identity(only_arg: Int) -> Int {\n only_arg\n}\n\nfn consumer() -> Int {\n identity(only_arg: " + lex + ")\n}\n") +} + +// THE CALL'S RESULT AS AN INT, OR NOTHING. The two identity rows below each assert an EXACT result for +// ONE argument, so each pays one front-end run where the pair previously paid two apiece: the two +// SOURCES ("3" and "8") are distinct text, which no memo collapses, and a row that asked about both +// carried the other's whole pipeline against its own enrolment budget. Exactness is what lets one row +// per argument keep what the conjunctions had -- a callee answering any constant fails the row whose +// argument it does not equal -- so this reader returns the magnitude rather than a Bool against an +// expectation. +fn cref_value_int_optional(v: RuntimeValue) -> Optional { + match v { + RuntimePrimitive { value: p } => + match integer_signed_i32_le_bytes_to_int(bytes: p.bytes) { + Accepted { value: magnitude, diagnostics: _ } => optional_present(value: magnitude) + Rejected { diagnostics: _ } => optional_absent() + } + _ => optional_absent() + } +} + +// THE ACCEPTANCE TARGET FOR THIS LANE, ENROLLED AS THE REFUSAL IT IS TODAY. The value path is written +// and EXECUTED by this claim -- both arguments are supplied and both results are compared -- so what +// remains when the boundary is repaired is inverting this assertion, not authoring the behaviour it +// checks. Writing it the other way round would land a red and specify the same thing. +// +// It refuses at the shared facts key, not at anything about calls or arguments: see +// v2.test.claim.callexec.synthetic_facts_key_collision, where more than one entry carries one key and +// those entries disagree on grounding. +// FLIPPED, AND THIS IS THE ACCEPTANCE TARGET THE ROW WAS WRITTEN FOR. Both arguments are supplied and +// both results are compared, so a callee that ignored its argument and answered one constant fails it: +// 3 in yields 3 out and 8 in yields 8 out. The refusal it used to count was NOT the shared facts key +// the old annotation blamed -- the synthetic-key collision is real and still enrolled separately, but +// it was not what held this row red. The boundary was the two disagreeing readers of a callee's arrow. +// +// ONE ARGUMENT PER ROW, AND THE RESULT READ EXACTLY. The pair "3 in yields 3 out" and "8 in yields 8 +// out" is two independent cases over two different sources, so forcing both through one row paid two +// full front-end runs against one enrolment budget for no coverage the split does not keep. The row +// below owns the "3" case and the row after it owns the "8" case; argument dependence is established +// by the two together, exactly as it was by the two conjuncts, because neither result is admissible +// for the other's argument. Asserting the EXACT magnitude is what makes each row carry that on its +// own: a callee answering any constant fails the row whose argument it does not equal. +test fn cref_argument_dependent_execution_reaches_the_callee_holds() -> Bool { + cref_executes_to(r: cref_identity_three_reading(), n: 3) +} + +// AND THE SAME CALLEE MUST NOT ANSWER A DIFFERENT ARGUMENT'S VALUE once it executes. THE VACUITY THIS +// ANNOTATION USED TO CONFESS IS GONE AND THE CONFESSION WAS STALE: it said nothing executes, so both +// negated conjuncts held for the wrong reason, and that stopped being true when the row above flipped. +// A reader who trusted the annotation would have discounted coverage that was already live, which is +// the same defect as claiming coverage that is not -- a stale honesty note misreports the rung either +// way (DESIGN section 4b(1)). +// +// IT IS NOW THE "8" CASE, READ EXACTLY, so it answers both halves of its own name from one front-end +// run: the callee executes to 8 and therefore does not answer 3. The negation is carried by the +// exactness rather than by a second run against the other magnitude. +test fn cref_the_identity_callee_never_answers_the_other_argument_holds() -> Bool { + cref_executes_to(r: cref_identity_eight_reading(), n: 8) +} + +// THE BOOL PAIR'S DISTINCT OUTCOMES, the same shape one type further on. A true-returning and a +// false-returning callee must produce DIFFERENT runtime answers; a repair that made both execute to +// the same value would satisfy "executes" and destroy the distinction the pair exists for. Enrolled as +// the refusal it is today, with the comparison written and executed. +fn cref_bool_false_source() -> Outcome { + cref_assemble(src: "module p\n\nfn falsity(only_arg: Int) -> Bool {\n false\n}\n\nfn consumer() -> Bool {\n falsity(only_arg: 3)\n}\n") +} + +// FLIPPED: THE PAIR SEPARATES. Two callees differing only in their constant Bool body now execute and +// produce DIFFERENT values. Execution is asserted on both sides before inequality is read, because two +// refusals are also unequal and would satisfy inequality alone. The values are not compared against a +// magnitude: this module is not the authority on how v2.std.logic represents its arms, and asserting a +// byte image here would be a second such authority (DESIGN section 3). +// +// EXECUTION IS NOT ASSERTED BY A SEPARATE CONJUNCT, AND THAT IS A STRENGTHENING, NOT A SAVING. The two +// leading cref_executes conjuncts established strictly LESS than cref_results_differ already must: +// that reader answers Absent for a refusal and ALSO for an accepted value that is not a primitive, +// where cref_executes answered true for the second. Removing them removes the weaker check. +// +// IT BOUGHT NO COST, AND SAYING SO IS THE POINT. The claim-local call memo already collapsed the +// repeated nullary source and eval calls, so claim_batch measures this row at the same cost with the +// conjuncts and without them -- the duplication was apparent in the text and absent in the run. That +// is worth recording because the reverse inference is the tempting one: a row that LOOKS like it calls +// the front end four times is not evidence that it does, and only the instrument decides. +test fn cref_the_bool_pair_separates_holds() -> Bool { + cref_results_differ(a: cref_bool_pair_reading(), b: cref_bool_false_reading()) +} + +// THE PRIMITIVE BYTES OF A CALL'S RESULT, for the one claim whose property is that two calls produce +// DIFFERENT values. The existing int reader decodes a four-byte signed image and a Bool is not one, so +// asserting "true" and "false" by magnitude would be asserting this module's guess at v2.std.logic's +// representation. Byte inequality is the property without the guess: a callee that ignored its +// declaration and answered one constant cannot satisfy it. +fn cref_value_bytes(v: RuntimeValue) -> Optional> { + match v { + RuntimePrimitive { value: p } => optional_present(value: p.bytes) + _ => optional_absent() + } +} + +// FALSE WHEN EITHER SIDE DID NOT EXECUTE, so the separation claim below pairs it with two execution +// assertions rather than reading inequality as evidence on its own: two refusals are also unequal. +fn cref_results_differ(a: CrefReading, b: CrefReading) -> Bool { + match a.result_bytes { + Absent => false + Present { value: xs } => + match b.result_bytes { + Absent => false + Present { value: ys } => !(xs == ys) + } + } +} + +// WHICH NODE THE REFUSAL IS LOCATED AT, BECAUSE THE REASON HAS TWO PRODUCERS. The three +// argument-dependent rows above refuse with ^eval_rejected_parameter_reference_unbound, and that symbol is +// emitted from two places that mean different things: v2.compiler.eval eval_callee_body_refusal_reason maps +// the ParameterReferenceBody ARM to it, located at the callee's declaring Arrow and raised BEFORE +// eval_bind_arrow_params; and the parameter-reference arm of eval_type_node_atom raises it at the USE when a +// binding is genuinely missing. One is a categorical refusal of a body FORM, the other is a real unbound +// parameter, and the reason alone cannot tell them apart. +// +// THAT AMBIGUITY COST FOUR PROBES. Reading the reason as a key mismatch led to lookups by full path and by +// leaf, and to binding under both spellings, none of which touched the failing fixture -- because a +// parameter-bodied callee never reaches binding at all, while the constant-bodied controls beside it do. So +// the locus is measured here rather than inferred, and it is measured against the EXACT nodes the production +// route uses rather than by rendering, spelling or occurrence class. +fn cref_fatal_locus_node(o: Outcome, name: Symbol) -> Optional { + match cref_eval_of_call_to(o: o, name: name) { + Absent => optional_absent() + Present { value: outcome } => + match outcome { + Accepted { value: _, diagnostics: _ } => optional_absent() + Rejected { diagnostics: ds } => + match ds.head.at { + NodeLocus { anchor: a } => optional_present(value: a.at) + _ => optional_absent() + } + } + } +} + +// The callee's declaring Arrow, through the same two production readers eval itself uses: the callee use, +// then the denotation infer recorded for it. +fn cref_callee_declaration(o: Outcome) -> Optional { + match cref_inferred_of(o: o) { + Absent => optional_absent() + Present { value: inferred } => + match cref_the_call_to(root: inferred.root, name: ^callee) { + Absent => optional_absent() + Present { value: call } => + match cref_callee_reference_optional(n: call) { + Absent => optional_absent() + Present { value: ref } => eval_callee_declaration_optional(tree: inferred, callee: ref) + } + } + } +} + +// THE ENTRY GATE ADMITS THE FORM, which is the climb this measurement located. The row that stood here +// asserted the refusal was located at the callee's DECLARING ARROW rather than at the parameter use, and that +// is how the firing producer was identified: ^eval_rejected_parameter_reference_unbound has two producers -- +// v2.compiler.eval eval_callee_body_refusal_reason maps the ParameterReferenceBody ARM to it before any +// binding happens, and eval_parameter_reference_value raises it at the USE when a binding is missing -- so +// the reason alone could not say which. Measuring the LOCUS could, and it said the gate. +// +// DESIGN section 4b(4): the measurement row does not retire, it flips. v2.std.node +// arrow_body_admits_eval_entry now admits ParameterReferenceBody, so the gate is asserted directly rather +// than through the locus of a refusal that no longer happens. It goes red if that policy arm is reverted. +test fn cref_the_entry_gate_admits_a_parameter_reference_body_holds() -> Bool { + arrow_body_admits_eval_entry(form: ParameterReferenceBody) +} + +// AND A MISSING BINDING STILL REFUSES, which is what keeps admitting the form from being a widening. The +// subject is supplied at one interface -- a parameter reference whose declaring path nothing bound, evaluated +// in an empty environment -- because what is under test is the lookup's refusal and not the route that +// reaches it. Without this row the repair could be satisfied by answering every parameter reference with +// something, which is the fail-open direction. +// +// IT ALSO PINS WHAT THE REASON NOW MEANS. After the gate opened, this symbol reports a MISSING BINDING and +// nothing else; a reader who sees it can stop looking for a body-form refusal. +test fn cref_a_parameter_reference_with_no_binding_refuses_holds() -> Bool { + match eval_parameter_reference_value( + node: parameter_reference_node( + qn: Cons { head: ^p, tail: Cons { head: ^nowhere, tail: Cons { head: ^x, tail: Empty } } }, + occurrence_id: OccurrenceSynthetic + ), + environment: empty_evaluation_environment() + ) { + Accepted { value: _, diagnostics: _ } => false + Rejected { diagnostics: ds } => symbol_eq(a: ds.head.reason, b: ^eval_rejected_parameter_reference_unbound) + } +} diff --git a/src/v2/test/claim/callexec/named_call_execution_test.dag b/src/v2/test/claim/callexec/named_call_execution_test.dag new file mode 100644 index 00000000000..ad92a81a63e --- /dev/null +++ b/src/v2/test/claim/callexec/named_call_execution_test.dag @@ -0,0 +1,48 @@ +module v2.test.callexec.named_call_execution + +import std.types { Bool, Int } + +// NATIVE EXECUTION OF A NAMED CALL. Grounding at infer is not execution: this runs through the real +// route, so the call must be typed AND evaluated. Bool-returning on purpose -- an Int result compared +// with `==` would couple the first execution proof to the equality operation, which has its own typing. +// +// THE FALSE CONTROL IS THE DISCRIMINATOR. A call that refuses and a call that returns false are +// different outcomes that a single positive row cannot separate: if the route reported both as "not +// holding", a refusal would read as a false answer. So one row must HOLD and the other must RETURN +// FALSE, and the pair together establishes that the body ran. +fn truth(only_arg: Int) -> Bool { + true +} + +fn falsity(only_arg: Int) -> Bool { + false +} + +// EXECUTING, AND THE PAIR DISCRIMINATES. The refusal this file recorded is gone: both calls run and +// answer their own bodies. The boundary was two disagreeing readers of a callee's arrow -- +// v2.compiler.infer infer_application_formal_args read it FACTS-BLIND while its caller's formals came +// from infer_application_callee_arrow_with_facts, so a named call (whose callee is a resolved +// declaration reference, a Conj and never an Arrow) produced no formal/actual pairs at all -- together +// with infer_declaration_reference_facts reading the declaration from the AUTHORED symbol_index, whose +// return atom is the source spelling that dag_binding_denotation does not denote. +// +// THESE ROWS WERE NOT ENROLLED BY ANYTHING BEFORE, and that is worth recording rather than quietly +// fixing. They were declared plain `fn`, so the claim route never ran them and the native pattern +// selected nothing either: a `*_test.dag` that enrols nothing is the decoration DESIGN section 4b +// names, and the floor's own discovery gate is what refused it. It surfaced only once the type errors +// ahead of it were repaired -- a refusal earlier in the phase had been masking it. The seven's own file +// (v2.test.parse.expression_bodied_fn_decl_parse) declares `test fn` at a `*_test.dag` path, which is +// the convention these rows now follow. +// +// THE FALSE CONTROL IS A NEGATION, NOT AN EQUALITY. Asserting `falsity(...) == false` would couple this +// execution proof to the equality operation, which has its own typing rule and its own standing -- the +// coupling the header above already refuses. `!falsity(...)` holds only if the call RETURNED false: a +// refusal fails the row, and a `true` answer fails it too, so the pair separates a refusal from a false +// answer, which is what a single positive row cannot do. +test fn nc_a_named_bool_call_executes_holds() -> Bool { + truth(only_arg: 1) +} + +test fn nc_the_false_returning_call_returns_false_holds() -> Bool { + !falsity(only_arg: 1) +} diff --git a/src/v2/test/claim/callexec/synthetic_facts_key_collision_test.dag b/src/v2/test/claim/callexec/synthetic_facts_key_collision_test.dag new file mode 100644 index 00000000000..83bbb427f9c --- /dev/null +++ b/src/v2/test/claim/callexec/synthetic_facts_key_collision_test.dag @@ -0,0 +1,195 @@ +module v2.test.claim.callexec.synthetic_facts_key_collision + +import v2.test.claim.callexec.declaration_reference_eval { + cref_bool_pair_source, + cref_source +} +import v2.compiler.infer { infer_entries_for_tree, inferred_facts_grounding_derived } +import v2.compiler.inferred_tree { InferredFactsEntry } +import v2.std.diagnostic { Accepted, Outcome, Rejected } +import v2.compiler.resolve { ResolvedTree } +import v2.std.logic { Bool } +import v2.std.node { Node } +import v2.std.optional { Absent, Optional, Present, optional_absent, optional_present } +import std.algebra { Cons, Empty } +import v2.std.algebra { fold_list } +import v2.std.collection { List } +import v2.std.integer { Int } +import v2.std.runtime { runtime_value_conj_node } + +// ONE FACTS KEY, TWO SUBJECTS, DISAGREEING FACTS -- and which one a consumer receives depends on scan +// order. +// +// v2.compiler.inferred_tree InferredTree keys its facts by Node and Node equality is STRUCTURAL, so +// two nodes agreeing on kind, children and occurrence identity are ONE KEY. std.occurrence_identity +// spells "no authored occurrence" as the NULLARY constructor OccurrenceSynthetic -- one VALUE, not one +// value per synthetic node -- so every structurally identical synthetic node collapses together. The +// lookup is a first-match scan over the entry list (v2.compiler.infer facts_map_from_entries), and its +// constructor checks each entry's own subject correspondence without establishing any uniqueness or +// conflict condition ACROSS entries that compare equal. +// +// The claims below enumerate infer's entries for one small program and measure the consequence: more +// than one entry carries the empty synthetic Conj as its key, and those entries DISAGREE on whether +// grounding was derived. So one key names at least two subjects whose facts differ, and a consumer +// asking under that key receives whichever the scan reaches first. +// +// BOTH DIRECTIONS ARE REACHABLE, WHICH IS WHY THIS IS A SAFETY FINDING AND NOT ONLY AN ANOMALY. The +// refusing direction is what this lane observed: a consumer receives an UNDERIVED entry and refuses +// with eval_rejected_grounding_not_derived at a node that is not its subject, which converts a +// fail-closed lookup miss into a grounding judgment no producer intended. The opposite direction is +// not hypothetical here, and that is what the second claim establishes: a DERIVED entry exists under +// the same key, so a subject whose grounding was never established can receive one that was. That is +// a fabricated plausible output rather than a refusal (DESIGN section 5), and nothing in the current +// relation makes it unreachable -- which of the two a given consumer gets is decided by entry order. +// +// WHAT THIS FILE DELIBERATELY DOES NOT CLAIM. It does not assert where the node a consumer queried +// came from. Two earlier readings of this refusal each inferred provenance from `Node == Node` -- first +// that the node lay inside the callee's encoded declaring path, then that it was built by the runtime +// value constructor -- and BOTH are withdrawn, for the same reason: an empty synthetic Conj compares +// equal to many unrelated nodes, so membership in any node set, and equality with any constructor's +// result, are both uninformative about origin. Equality is exactly the relation under suspicion here, +// so it cannot be the instrument that establishes provenance. These claims therefore assert only what +// enumeration shows: that the key is shared and the facts under it conflict. +// +// The traversal path was measured separately, with a temporary diagnostic that gave each grounding +// demand site in v2.compiler.eval its own reason symbol; that instrument is not retained, and its +// result is recorded in this change's commit message rather than asserted here, because asserting it +// would require keeping the instrumentation in the evaluator. +// WHY EVERY ROW HERE EXECUTES THE WHOLE FRONT END, AND WHY THAT IS NOT CONVERTED AWAY. DESIGN +// section 3's witness rule says a claim's inputs belong at the boundary it discriminates, as supplied +// values, and the rows below supply none: each assembles authored text, resolves it and infers over it. +// That was interrogated row by row against the rule rather than defended by habit, and the reason it +// stands is the one DESIGN section 4b states for the top rung -- ASK WHETHER THE CHECK'S RED IS +// AUTHORABLE BEFORE WRITING THE CHECK. +// +// THE SUBJECT OF THESE ROWS IS THE ENTRY POPULATION INFER ACTUALLY EMITS, not what one downstream +// function answers for a shape. The claims count how many entries of one real compilation share one +// key and whether they agree -- and a supplied entry list would let this file CHOOSE that count, which +// is the whole question. There is no red to author against a fixture here: the collision would be +// asserted by construction. So the entries stay the real producer's, and the cost stays. +// +// SO THESE ROWS ARE THE EXECUTION OF THE REAL PATH -- the second half of the same rule, which forbids +// supplying inputs anywhere if it removes the last execution of the producer. Deleting resolve's +// projection lowering, or infer's declared-field read, must make a control here fail, and does. +// +// WHAT WOULD ACTUALLY MOVE THEIR COST is not a narrower claim but a provider for the demand: their +// nullary source producers are pure functions of module-constant text, which is the shape +// v2.workflow.floor_pure_producer_share already serves across claims for hundreds of peer fixtures, +// including the per-fixture assembled and resolved trees rostered there. Enrolment is NOT asserted or +// proposed here, because that roster's own admission criterion is a measured serve-below-recompute on +// a required-floor receipt, which this file cannot produce; naming the route is the honest half. +fn sfk_entries(o: Outcome) -> Optional> { + match o { + Rejected { diagnostics: _ } => optional_absent() + Accepted { value: resolved, diagnostics: _ } => + match infer_entries_for_tree(tree: resolved) { + Rejected { diagnostics: _ } => optional_absent() + Accepted { value: entries, diagnostics: _ } => optional_present(value: entries) + } + } +} + +fn sfk_key() -> Node { + runtime_value_conj_node(children: Empty) +} + +// ONE INFER PER FIXTURE, READ BY EVERY ROW THAT ASKS IT. Each fixture's entries are produced ONCE, by a +// nullary producer the floor serves warm (v2.workflow.floor_pure_producer_share), and the rows read the +// decided counts: whether infer produced any entry, how many entries stand under the synthetic key, and how +// many of those are derived. Nothing is supplied -- the producer runs the real assemble -> resolve -> +// infer path -- so the rows keep their reds; the counts are the old per-row folds over one entry list. +type SfkCounts { + produced: Bool + under_key: Int + derived_under_key: Int +} + +fn sfk_counts(o: Outcome) -> SfkCounts { + match sfk_entries(o: o) { + Absent => SfkCounts { produced: false, under_key: 0, derived_under_key: 0 } + Present { value: entries } => + SfkCounts { + produced: sfk_nonempty(entries: entries), + under_key: fold_list( + xs: entries, + empty: 0, + cons: fn(rest, entry) { if entry.node == sfk_key() { rest + 1 } else { rest } } + ), + derived_under_key: fold_list( + xs: entries, + empty: 0, + cons: fn(rest, entry) { + if entry.node == sfk_key() { + if inferred_facts_grounding_derived(facts: entry.facts) { rest + 1 } else { rest } + } else { + rest + } + } + ) + } + } +} + +fn sfk_nonempty(entries: List) -> Bool { + match entries { + Cons { head: _, tail: _ } => true + Empty => false + } +} + +fn sfk_cref_source_counts() -> SfkCounts { + sfk_counts(o: cref_source()) +} + +fn sfk_bool_pair_counts() -> SfkCounts { + sfk_counts(o: cref_bool_pair_source()) +} + +// THE POSITIVE CONTROL FOR THE INSTRUMENT: infer produces entries for this program at all, so a zero +// count below is a shared key and not a failed inference. +test fn sfk_infer_produces_entries_holds() -> Bool { + sfk_cref_source_counts().produced +} + +// THE KEY IS NO LONGER SHARED, AND THESE THREE ROWS ARE THE FLIPPED EVIDENCE OF THAT CLIMB. This file's +// whole subject was a facts-key COLLISION: structurally identical synthetic nodes keyed one facts entry, so +// more than one entry stood under a single key and eval could refuse at a node that was not its subject. +// Every row here asserted `total > 1` -- that the collision existed -- and all three now fail because +// gunbc#12582's facts-key work eliminated it. MEASURED: exactly ONE entry stands under the key, for both +// fixtures. +// +// DESIGN section 4b(4) IS WHY THEY ARE FLIPPED RATHER THAN DELETED. A climb deletes the lower-rung +// production handling and KEEPS the evidence: the discriminating red becomes the permanent control that the +// higher rung stays real. So each row keeps its fixture and its counting method and asserts the separation +// instead of the collision. They go red the moment two entries share a key again, which is the transition +// the identity owner needs to hear about. +// +// THE COUNT IS ENUMERATED, NOT INFERRED FROM A LOOKUP, and that was true of the original rows for the same +// reason it is true of these: a lookup answering something proves only that SOME entry matched, while +// counting proves how many stand under the key. Asserting `== 1` rather than `<= 1` is deliberate -- it +// keeps the row non-vacuous, because a fixture that stopped producing the node at all would answer 0 and a +// `<= 1` row would stay green while testing nothing. +test fn sfk_exactly_one_entry_stands_under_the_key_holds() -> Bool { + sfk_cref_source_counts().under_key == 1 +} + +// AND THAT ENTRY IS GROUNDED, which is the half of the original pair that still says something about +// quality rather than about cardinality: separation would be worthless if the surviving entry were +// underived. +test fn sfk_the_single_entry_under_the_key_is_derived_holds() -> Bool { + let counts = sfk_cref_source_counts() + let total = counts.under_key + let derived = counts.derived_under_key + (total == 1) && (derived == total) +} + +// THE SEPARATION IS NOT AN ARTIFACT OF ONE SOURCE TEXT, which is what the original third row established in +// the opposite direction: a second fixture reached the same collision, so it was a property of the keying +// relation over ordinary programs. It now reaches the same separation, so the property still holds of the +// relation and not of one text. +test fn sfk_the_separation_is_not_specific_to_one_fixture_holds() -> Bool { + let counts = sfk_bool_pair_counts() + let total = counts.under_key + let derived = counts.derived_under_key + (total == 1) && (derived == total) +} diff --git a/src/v2/test/claim/declared_parameter_order_test.dag b/src/v2/test/claim/declared_parameter_order_test.dag index b553a82bd6e..f6b6c3f5576 100644 --- a/src/v2/test/claim/declared_parameter_order_test.dag +++ b/src/v2/test/claim/declared_parameter_order_test.dag @@ -211,6 +211,13 @@ fn dpo_canonical_domain(arrow: Node) -> Node { } // An application of `arrow` to nothing: infer_application_formals reads only the callee. +// THE ENTRIES LIST IS EMPTY, AND THAT IS THE CORRECT SUPPLIED VALUE RATHER THAN A STUB. This lane +// widened infer_application_formals to read a callee's arrow through the CALLEE'S FACTS as well, for +// the case where the callee is a resolved declaration reference and not an Arrow node. Here the +// fixture supplies the Arrow AS the callee (dpo_application puts it at positional 0), so +// infer_operator_arrow answers from the node itself and the facts route is never consulted. An empty +// entries list therefore states exactly the fixture's premise -- no facts are needed to read this +// callee -- and a non-empty one would be inventing a hypothesis this claim's subject does not have. fn dpo_application(arrow: Node) -> Node { Node { kind: ComputationNode { behavior: Transform }, @@ -220,7 +227,7 @@ fn dpo_application(arrow: Node) -> Node { } fn dpo_formal_labels(arrow: Node) -> List { - match infer_application_formals(node: dpo_application(arrow: arrow)) { + match infer_application_formals(node: dpo_application(arrow: arrow), entries: []) { FormalsInDeclaredOrder { formals: fs } => fold(fs, init: [], f: fn(acc, f) { concat(acc, [f.parameter_identity]) }) FormalsUnresolved => [] FormalsOrderRefused { reason: _ } => [] @@ -228,7 +235,7 @@ fn dpo_formal_labels(arrow: Node) -> List { } fn dpo_refusal_reason(arrow: Node) -> List { - match infer_application_formals(node: dpo_application(arrow: arrow)) { + match infer_application_formals(node: dpo_application(arrow: arrow), entries: []) { FormalsOrderRefused { reason: r } => [r] FormalsInDeclaredOrder { formals: _ } => [] FormalsUnresolved => [] diff --git a/src/v2/test/claim/field_projection/field_projection_stages_test.dag b/src/v2/test/claim/field_projection/field_projection_stages_test.dag new file mode 100644 index 00000000000..ace9bd319d4 --- /dev/null +++ b/src/v2/test/claim/field_projection/field_projection_stages_test.dag @@ -0,0 +1,758 @@ +module v2.test.claim.field_projection.field_projection_stages + +import v2.compiler.resolve { ResolvedTree } +import v2.compiler.infer { infer } +import v2.compiler.inferred_tree { InferredTree } +import v2.compiler.name_resolve { Admission, ResolutionSubject } +import v2.compiler.program_assembly { assemble_program_from_ingest } +import v2.compiler.source_authority { DagSourceReadWitness } +import v2.extdeps.languages.dag { dag_language_model } +import extdeps.communication.medium { Lossless, Medium } +import std.algebra { Cons, Empty } +import v2.std.cross_tree.import_model { V2Tree } +import v2.std.artifact { Artifact, SourceFile } +import v2.std.diagnostic { + Some, + diagnostics_has_reason, Accepted, NodeLocus, Outcome, Rejected } +import v2.std.logic { Bool } +import v2.std.node { Atom, Conj, Node, Symbol, TypeNode, symbol_eq } +import v2.std.qualified_name { + parameter_reference_path_optional, + qualified_name_last_segment, declaration_reference_path_optional, qualified_name_spine_shape_present } +import v2.std.node_query { FieldProjection, declared_field_named, field_projection_optional } +import v2.std.symbol_index { symbol_index_lookup } +import v2.compiler.infer { + infer_atom_binding_sym, + infer_type_equal_ignoring_provenance, + inferred_facts_grounding_derived, + inferred_facts_resolved_type +} +import v2.compiler.inferred_tree { InferredFacts } +import v2.compiler.eval { eval_field_projection } +import v2.std.runtime { + RuntimeAggregate, + RuntimeAggregateValue, + RuntimeFieldValue, + RuntimePrimitive, + RuntimePrimitiveValue, + RuntimeValue +} +import v2.std.integer { Int, integer_int_to_signed_i32_le_bytes, integer_signed_i32_le_bytes_to_int } +import std.occurrence_identity { OccurrenceSynthetic } +import v2.std.witness { Holds, Violates } +import v2.std.node { NodeFold, fold_node } +import v2.std.optional { Absent, Optional, Present, optional_absent, optional_present } + +// WHERE A FIELD PROJECTION STOPS TODAY, measured stage by stage rather than asserted. The normalized +// shape exists and is documented as a declared frontier: v2.std.node_query field_projection_node +// builds a two-edge Conj (a base edge to the receiver's lowered node, a field edge to the field-name +// atom) and its own header states what it does NOT do -- "it does not check that `f` IS a declared +// field of the receiver's type ... it is the typecheck stage's, over this node". +// +// This file establishes the first boundary before anything is repaired, so a later green is a change +// in behaviour and not a change in what was being asked. +// WHY EVERY ROW HERE EXECUTES THE WHOLE FRONT END, AND WHY THAT IS NOT CONVERTED AWAY. DESIGN +// section 3's witness rule says a claim's inputs belong at the boundary it discriminates, as supplied +// values, and the rows below supply none: each assembles authored text, resolves it and infers over it. +// That was interrogated row by row against the rule rather than defended by habit, and the reason it +// stands is the one DESIGN section 4b states for the top rung -- ASK WHETHER THE CHECK'S RED IS +// AUTHORABLE BEFORE WRITING THE CHECK. +// +// THE SUBJECT OF THESE ROWS IS WHAT THE REAL INDEX AND THE REAL LOWERING PRODUCE, not what one +// downstream function answers for a shape. The projection interface itself -- v2.compiler.infer +// infer_field_projection_facts -- takes a receiver payload, a facts entry and a ResolvedTree, and +// supplying those makes the answer a restatement of the fixture: "two fields of different declared +// types ground differently" is a fact about the index the front end builds, and against a hand-built +// index it is true by how the fixture was written. That red is not authorable, which makes the +// converted row a decoration rather than a weaker wall, and DESIGN says a decoration is worse than an +// absent check because it will be cited as coverage. THE EVAL ROWS AT THE END OF THIS FILE ARE THE +// CONTRAST AND THE PRECEDENT: there the supplied value is a runtime aggregate whose field layout the +// claim does NOT choose the answer from, so supplying it discriminates and costs almost nothing. +// +// SO THESE ROWS ARE THE EXECUTION OF THE REAL PATH -- the second half of the same rule, which forbids +// supplying inputs anywhere if it removes the last execution of the producer. Deleting resolve's +// projection lowering, or infer's declared-field read, must make a control here fail, and does. +// +// WHAT WOULD ACTUALLY MOVE THEIR COST is not a narrower claim but a provider for the demand: their +// nullary source producers are pure functions of module-constant text, which is the shape +// v2.workflow.floor_pure_producer_share already serves across claims for hundreds of peer fixtures, +// including the per-fixture assembled and resolved trees rostered there. Enrolment is NOT asserted or +// proposed here, because that roster's own admission criterion is a measured serve-below-recompute on +// a required-floor receipt, which this file cannot produce; naming the route is the honest half. +data fps_artifact: Artifact = Artifact { + kind: SourceFile, + id: ^field_projection_stages_artifact, + file_path: "src/v2/pilot/field_projection_stages_pilot.dag" +} + +fn fps_assemble(src: String) -> Outcome { + assemble_program_from_ingest( + ingest: Cons { + head: DagSourceReadWitness { + source: Medium { carried: src, fidelity: Lossless }, + artifact: fps_artifact, + compilation_unit: ^field_projection_stages_cu, + source_root: V2Tree + }, + tail: Empty + }, + admission: Admission { subject: ResolutionSubject { name: Cons { head: ^p, tail: Empty } }, imports: Empty }, + lm: dag_language_model() + ) +} + +// THE RECEIVER IS A PLAIN PARAMETER, deliberately: a match arm or a lambda would add a binding +// question that is not this subject, and an earlier investigation established that the same refusal +// appears with no match arm or lambda involved. +fn fps_valid_field_source() -> Outcome { + fps_assemble(src: "module p\n\ntype Box {\n tree: Int\n}\n\nfn f(b: Box) -> Int {\n b.tree\n}\n") +} + +// THE SAME RECEIVER WITH A FIELD THE TYPE DOES NOT DECLARE. This is the control that makes a later +// green meaningful: a projection stage that admits every field name would satisfy the valid case and +// establish nothing. +fn fps_absent_field_source() -> Outcome { + fps_assemble(src: "module p\n\ntype Box {\n tree: Int\n}\n\nfn f(b: Box) -> Int {\n b.absent_field\n}\n") +} + +// THE SAME RECEIVER WITH THE PROJECTION REMOVED, so the fixture family isolates projection from +// binding: if this also failed, the subject would be the parameter and not the field access. +fn fps_no_projection_source() -> Outcome { + fps_assemble(src: "module p\n\ntype Box {\n tree: Int\n}\n\nfn f(b: Box) -> Int {\n 7\n}\n") +} + +fn fps_resolves(o: Outcome) -> Bool { + match o { + Accepted { value: _, diagnostics: _ } => true + Rejected { diagnostics: _ } => false + } +} + +fn fps_infers(o: Outcome) -> Bool { + match o { + Rejected { diagnostics: _ } => false + Accepted { value: resolved, diagnostics: _ } => + match infer(tree: resolved) { + Accepted { value: _, diagnostics: _ } => true + Rejected { diagnostics: _ } => false + } + } +} + +// THE FIXTURE'S POSITIVE CONTROL: the receiver alone reaches resolve and infer, so a red below is +// about the projection and not about the parameter, the record declaration or the assembly. +test fn fps_the_receiver_without_a_projection_resolves_holds() -> Bool { + fps_resolves(o: fps_no_projection_source()) +} + +test fn fps_the_receiver_without_a_projection_infers_holds() -> Bool { + fps_infers(o: fps_no_projection_source()) +} + +test fn fps_a_valid_field_projection_resolves_holds() -> Bool { + fps_resolves(o: fps_valid_field_source()) +} + +test fn fps_a_valid_field_projection_infers_holds() -> Bool { + fps_infers(o: fps_valid_field_source()) +} + +// AN ABSENT FIELD MUST NOT BE ADMITTED. Asserted as a refusal at whatever stage currently refuses it, +// so this claim stays honest before and after the repair: what it forbids is ACCEPTANCE, which is the +// property that must never hold. +test fn fps_an_absent_field_does_not_infer_holds() -> Bool { + !fps_infers(o: fps_absent_field_source()) +} + +fn fps_projection_node_optional(root: Node) -> Optional { + fold_node( + n: root, + algebra: NodeFold { + init: fn(n0) { + match field_projection_optional(n: n0) { + Present { value: _ } => optional_present(value: n0) + Absent => optional_absent() + } + }, + step: fn(acc, _e, child) { + match acc { Present { value: _ } => acc Absent => child } + } + } + ) +} + +fn fps_projection_facts(o: Outcome) -> Optional { + match o { + Rejected { diagnostics: _ } => optional_absent() + Accepted { value: resolved, diagnostics: _ } => + match infer(tree: resolved) { + Rejected { diagnostics: _ } => optional_absent() + Accepted { value: inferred, diagnostics: _ } => + match fps_projection_node_optional(root: inferred.root) { + Absent => optional_absent() + Present { value: proj } => inferred.facts.lookup(proj) + } + } + } +} + +// THE PROJECTION EXISTS IN THE RESOLVED TREE AT ALL. Separate from whether it grounds, because a tree +// with no projection node would make every grounding claim below vacuous in the other direction. +test fn fps_the_resolved_tree_carries_a_field_projection_holds() -> Bool { + match fps_projection_facts(o: fps_valid_field_source()) { + Present { value: _ } => true + Absent => false + } +} + +test fn fps_a_valid_field_projection_grounds_holds() -> Bool { + match fps_projection_facts(o: fps_valid_field_source()) { + Absent => false + Present { value: facts } => inferred_facts_grounding_derived(facts: facts) + } +} + +// AND IT GROUNDS TO THE RIGHT FIELD'S TYPE, asserted without naming a canonical spelling. Two fields +// of DIFFERENT declared types are projected from the same receiver: if the arm read the receiver's own +// type, its own node, or a fixed field, the two projections would ground to the SAME type. They must +// differ, and `.tree` must further agree with how the compiler types an Int-declared PARAMETER in an +// unrelated fixture -- an independent route to the same answer, so the claim is not the implementation +// compared with itself. +// THE DECLARED RETURN TRACKS THE PROJECTED FIELD, AND IT HAS TO. This fixture used to declare `-> Int` +// for both fields, so the `flag` case was `fn f(b: Box) -> Int { b.flag }` -- an ILL-TYPED program, a +// Bool body at an Int return. It inferred anyway because v2.compiler.infer's declared-position judge +// could not denote a declared Bool (it arrives as v2.std.logic bool_node, not as a binding, so the +// binding->value-type join answered Absent and the comparison was SKIPPED). Once that gate consulted +// infer_established_value_type_optional the mismatch refused, correctly, and this row went red -- so the +// row had been resting on a defect, not on the property it claims. +// +// The property is unchanged: two fields of DIFFERENT declared types, projected from the same receiver, +// must ground differently. Only the fixture is now well-typed, which is what lets the projection's +// grounding be the thing under test rather than the refusal. +fn fps_two_field_source(field: String) -> Outcome { + fps_assemble(src: "module p\n\ntype Box {\n tree: Int\n flag: Bool\n}\n\nfn f(b: Box) -> " + fps_field_declared_type(field: field) + " {\n b." + field + "\n}\n") +} + +fn fps_field_declared_type(field: String) -> String { + if field == "flag" { "Bool" } else { "Int" } +} + +fn fps_inferred_of(o: Outcome) -> Optional { + match o { + Rejected { diagnostics: _ } => optional_absent() + Accepted { value: resolved, diagnostics: _ } => + match infer(tree: resolved) { + Rejected { diagnostics: _ } => optional_absent() + Accepted { value: inferred, diagnostics: _ } => optional_present(value: inferred) + } + } +} + +fn fps_int_parameter_type_optional() -> Optional { + match fps_inferred_of(o: fps_assemble(src: "module p\n\nfn g(i: Int) -> Int {\n i\n}\n")) { + Absent => optional_absent() + Present { value: inferred } => fps_body_atom_type_optional(inferred: inferred) + } +} + +// The leaf of a parameter reference's declaring path, compared by symbol. The path is the declaration's +// own, so the parameter is identified by its last segment rather than by a bare spelling. +fn fps_path_leaf_is(path: QualifiedName, name: Symbol) -> Bool { + match qualified_name_last_segment(qn: path) { + Present { value: leaf } => symbol_eq(a: leaf, b: name) + Absent => false + } +} + +// THE PARAMETER IS READ AS A PARAMETER REFERENCE, NOT AS A BARE ATOM, which is where this helper had to +// move. It used to hunt the inferred tree for `Atom { identity: ^i }` and read that node's facts. A named +// fn's parameter no longer reaches infer that way: v2.compiler.resolve now mints it as a path-keyed +// parameter reference and infer grounds it through the resolved declarations, so the bare-atom search found +// nothing and `fps_int_parameter_type_optional` answered Absent -- which failed +// `fps_the_int_field_grounds_as_an_int_parameter_does` on its COMPARISON BASIS rather than on its subject: +// a probe run at the time established that the field's own grounding was still present and only the +// parameter half had gone Absent. +// +// THE ROW'S SUBJECT IS UNCHANGED BY THIS REPAIR, and that is the point of fixing the reader rather than the +// assertion. What the row establishes is that two independent inference routes agree on one type: a +// projected field grounds to the same Int a parameter does. Re-pointing the reader keeps that comparison; +// weakening it to compare against a directly constructed Int node would have made the row share its +// derivation with its subject, which is a control that cannot discriminate. +fn fps_body_atom_type_optional(inferred: InferredTree) -> Optional { + fold_node( + n: inferred.root, + algebra: NodeFold { + init: fn(n0) { + match parameter_reference_path_optional(node: n0) { + Present { value: path } => + if fps_path_leaf_is(path: path, name: ^i) { + match inferred.facts.lookup(n0) { + Absent => optional_absent() + Present { value: facts } => + match inferred_facts_resolved_type(facts: facts) { + Violates { diagnostic: _ } => optional_absent() + Holds { value: ty } => optional_present(value: ty) + } + } + } else { + optional_absent() + } + _ => optional_absent() + } + }, + step: fn(acc, _e, child) { + match acc { Present { value: _ } => acc Absent => child } + } + } + ) +} + +test fn fps_two_fields_of_different_types_ground_differently_holds() -> Bool { + match fps_grounded_type_optional(o: fps_two_field_source(field: "tree")) { + Absent => false + Present { value: int_ty } => + match fps_grounded_type_optional(o: fps_two_field_source(field: "flag")) { + Absent => false + Present { value: bool_ty } => + !infer_type_equal_ignoring_provenance(a: int_ty, b: bool_ty) + } + } +} + +test fn fps_the_int_field_grounds_as_an_int_parameter_does_holds() -> Bool { + match fps_grounded_type_optional(o: fps_two_field_source(field: "tree")) { + Absent => false + Present { value: field_ty } => + match fps_int_parameter_type_optional() { + Absent => false + Present { value: param_ty } => + infer_type_equal_ignoring_provenance(a: field_ty, b: param_ty) + } + } +} + +fn fps_grounded_type_optional(o: Outcome) -> Optional { + match fps_projection_facts(o: o) { + Absent => optional_absent() + Present { value: facts } => + match inferred_facts_resolved_type(facts: facts) { + Violates { diagnostic: _ } => optional_absent() + Holds { value: ty } => optional_present(value: ty) + } + } +} + +// THE THIRD STAGE, AT THE EVALUATOR'S OWN BOUNDARY. The receiver's VALUE is supplied here rather than +// computed, which is DESIGN section 3's witness rule and not a shortcut: this claim's subject is one +// interface -- what eval returns for a projection over a given aggregate -- and deriving the aggregate by +// executing a constructor would re-run production the claim is not about. +// +// THE PAIRING OBLIGATION IS DISCHARGED BY A CLAIM IN THIS FILE, not by assertion: the real producer +// emits this shape, and fps_the_resolved_tree_carries_a_field_projection asserts exactly that over the +// production route (assemble -> resolve -> infer on authored source). So a supplied value here is a +// hypothesis about a boundary that another claim shows is inhabited. +// +// WHY THE VALUE IS SUPPLIED RATHER THAN COMPUTED, measured rather than assumed. Two surface forms that +// should deliver a projection to eval do not: `Box { .. }.tree` and `make().tree` both resolve and infer +// with NO field-projection node in the tree, while the parameter form `b.tree` does produce one. So the +// only projection this corpus's surface syntax currently produces has an UNBOUND receiver at eval, whose +// binding is blocked behind the frozen facts-key question. That is a finding, recorded in this change, +// and it is why the executed evidence for eval is at this boundary. +fn fps_int_primitive(n: Int) -> RuntimeValue { + RuntimePrimitive { + value: RuntimePrimitiveValue { + primitive_type: fps_atom(s: ^fps_int_type), + bytes: integer_int_to_signed_i32_le_bytes(value: n) + } + } +} + +fn fps_atom(s: Symbol) -> Node { + Node { + kind: TypeNode { connective: Atom { identity: s } }, + children: [], + occurrence_id: OccurrenceSynthetic + } +} + +fn fps_box_value() -> RuntimeValue { + RuntimeAggregate { + value: RuntimeAggregateValue { + aggregate_type: fps_atom(s: ^fps_box_type), + fields: Cons { + head: RuntimeFieldValue { field: ^tree, value: fps_int_primitive(n: 7) }, + tail: Cons { + head: RuntimeFieldValue { field: ^other, value: fps_int_primitive(n: 9) }, + tail: Empty + } + } + } + } +} + +fn fps_projection_over(field: Symbol) -> FieldProjection { + FieldProjection { base: fps_atom(s: ^fps_base), field: field } +} + +fn fps_eval_projection_of(field: Symbol, receiver: RuntimeValue) -> Outcome { + eval_field_projection( + node: fps_atom(s: ^fps_projection_site), + projection: fps_projection_over(field: field), + args: Cons { head: receiver, tail: Empty } + ) +} + +fn fps_evaluates_to(o: Outcome, n: Int) -> Bool { + match o { + Rejected { diagnostics: _ } => false + Accepted { value: v, diagnostics: _ } => + match v { + RuntimePrimitive { value: p } => + match integer_signed_i32_le_bytes_to_int(bytes: p.bytes) { + Accepted { value: magnitude, diagnostics: _ } => magnitude == n + Rejected { diagnostics: _ } => false + } + _ => false + } + } +} + +test fn fps_eval_projects_the_named_field_holds() -> Bool { + fps_evaluates_to(o: fps_eval_projection_of(field: ^tree, receiver: fps_box_value()), n: 7) +} + +// AND IT SELECTS BY NAME, NOT BY POSITION: the second field holds a different value, so an arm reading +// the first field regardless would pass the claim above and fail this one. +test fn fps_eval_selects_the_second_field_by_name_holds() -> Bool { + fps_evaluates_to(o: fps_eval_projection_of(field: ^other, receiver: fps_box_value()), n: 9) +} + +// A FIELD THE VALUE DOES NOT CARRY REFUSES rather than yielding a default. Reaching this state means the +// value and the type disagree, since infer has already established the type declares the field. +test fn fps_eval_refuses_a_field_the_value_lacks_holds() -> Bool { + match fps_eval_projection_of(field: ^absent_field, receiver: fps_box_value()) { + Rejected { diagnostics: _ } => true + Accepted { value: _, diagnostics: _ } => false + } +} + +// AND A RECEIVER THAT IS NOT AN AGGREGATE REFUSES: there is nothing to select from, and answering +// anything would be fabrication. +test fn fps_eval_refuses_a_non_aggregate_receiver_holds() -> Bool { + match fps_eval_projection_of(field: ^tree, receiver: fps_int_primitive(n: 7)) { + Rejected { diagnostics: _ } => true + Accepted { value: _, diagnostics: _ } => false + } +} + +// THE SEVEN'S ACTUAL RECEIVER IS A MATCH-ARM BINDER, AND THAT SHAPE IS BLOCKED BEFORE PROJECTION EVER +// APPLIES. In v2.test.parse.expression_bodied_fn_decl_parse the projection is `artifact.tree` where +// `artifact` is bound by the arm `Accepted { value: artifact, diagnostics: d }` -- a different binder +// kind from the parameter receiver above, so whether this repair reaches it is its own fact. +// +// MEASURED, AND THE ISOLATING CONTROL IS THE ONE THAT MATTERS. The match form RESOLVES -- so resolve's +// projection arm handles a match binder's head -- and it now INFERS as well, with a projection body and +// with a literal body alike: the match-arm boundary this section first recorded as the seven's refusal +// (body_lowering_reason_match_arm_navigation_refused) is gone. What remains at this receiver is that the +// projection does not GROUND, which the rewritten row below asserts in that direction rather than as a +// refusal that no longer happens. +// +// AN EARLIER VERSION OF THIS SECTION CLAIMED MORE THAN IT MEASURED. It asserted that an absent field off +// a match binder is not admitted, and that claim PASSED -- vacuously, because the valid projection off a +// match binder does not infer either, so both arms refuse and the assertion distinguished nothing. It is +// replaced by the isolating pair below, which states what is actually established. +// +// WHY THE SEVEN ARE NOT RUN AS THE EVIDENCE HERE. Those seven claims pass under the development runner +// both before and after this change, because that runner resolves them with the SEED compiler; their +// blocker is v2's OWN front end on the native route. A green from running them would be evidence about +// the seed, not about this repair -- a green signal that was never about the property claimed. +fn fps_match_binder_source(field: String) -> Outcome { + fps_assemble(src: "module p\n\ntype Art {\n tree: Int\n}\n\ntype Holder\n = Wrapped { value: Art }\n\nfn f(h: Holder) -> Int {\n match h {\n Wrapped { value: artifact } => artifact." + field + "\n }\n}\n") +} + + +// RESOLVE REACHES THE MATCH BINDER'S PROJECTION: the head is bound on the chain and the whole path names +// no declaration, so resolve commits the projection shape exactly as it does for a parameter receiver. +test fn fps_a_match_binder_receiver_resolves_holds() -> Bool { + fps_resolves(o: fps_match_binder_source(field: "tree")) +} + +// A MATCH-ARM BINDER AS A RECEIVER IS NOT YET TYPED, AND THIS ROW PINS EXACTLY HOW. It is the population of +// the DECLARED RUNG DROP gunbc.rung_drop match_arm_binder_typing_lost_to_lexical_carrier: gunbc#12641 typed a +// match-arm binder, and that typing rode on infer_parameter_scope_search, which #12766 narrowed to lambdas and +// main's lexical-reference cut deleted. The binder's use is now a lexical reference whose recorded binding +// declares no type, so the program INFERS and the projection off the binder is accepted UNDERIVED -- not +// refused, and not fabricated. (Before the pattern-field reader read a lexical binder, the whole program +// refused with infer_grounding_not_derived; that was a reader defect, repaired in gunbc#12506, and is why this +// row no longer asserts a refusal.) +// +// EXACT ROUTE, SO AN UNRELATED REFUSAL REDS IT: the program must infer, the projection node must be found in +// the inferred tree, and ITS facts must carry no derived grounding. A program refused for any cause, or a +// projection typed by some other route, makes it false. +// +// WHY THIS IS NOT "EVERYTHING IS UNDERIVED": the PARAMETER receiver in this same file grounds +// (fps_a_valid_field_projection_grounds) and its absent field IS refused (fps_an_absent_field_does_not_infer). +// Those are the discriminating positive controls. RESTORATION TRIGGER, the drop's: match-arm binder typing +// through the lexical-binding carrier (N7-3). This row then flips to the projection grounded, and under DESIGN +// section 4b(4) it stays as the control that the restoration is real. +test fn fps_a_match_binder_receiver_is_not_yet_typed_holds() -> Bool { + match fps_match_binder_source(field: "tree") { + Rejected { diagnostics: _ } => false + Accepted { value: resolved, diagnostics: _ } => + match infer(tree: resolved) { + Rejected { diagnostics: _ } => false + Accepted { value: inferred, diagnostics: _ } => + match fps_projection_node_optional(root: inferred.root) { + Absent => false + Present { value: proj } => + match inferred.facts.lookup(proj) { + Absent => false + Present { value: facts } => !inferred_facts_grounding_derived(facts: facts) + } + } + } + } +} + +// THE PROJECTION NODE IS THERE, IN THE RESOLVED TREE, so the row above is about a receiver whose projection +// resolve really built, read independently of infer. +test fn fps_a_match_binder_projection_is_in_the_resolved_tree_holds() -> Bool { + match fps_match_binder_source(field: "tree") { + Rejected { diagnostics: _ } => false + Accepted { value: resolved, diagnostics: _ } => + match fps_projection_node_optional(root: resolved.root) { + Present { value: _ } => true + Absent => false + } + } +} + + +// A MODULE-LEVEL `data` VALUE IS STILL PROJECTABLE, AND THIS ROW EXISTS SO THE 3d REPAIR CANNOT QUIETLY +// BECOME "ONLY LEXICAL VALUES MAY HAVE FIELDS". resolve_projection_base admits a BoundAtRoot head only +// where there are no field segments, because a dotted path through a module-root DECLARATION is a +// qualified name rather than a projection. A root-bound `data` value is the case that reading could +// over-prohibit: `cfg` names a value, not a namespace segment, so `cfg.tree` is an ordinary projection and +// must keep working. +// +// THE ROW REPORTS WHAT IS TRUE RATHER THAN WHAT WOULD BE TIDY (DESIGN section 4d: do not forbid more of the +// world than the evidence supports, and do not under-assert either). If this holds, the repair is scoped to +// namespace segments as intended. If it does not, the repair HAS over-prohibited and that is a defect to +// fix rather than a frontier to declare -- the row's reason, not merely its verdict, is what says which. +fn fps_root_data_projection_source() -> Outcome { + fps_assemble(src: "module p\n\ntype Box {\n tree: Int\n}\n\ndata cfg: Box = Box { tree: 1 }\n\nfn f() -> Int {\n cfg.tree\n}\n") +} + +// MEASURED, AND IT IS NOT YET SUPPORTED -- BY A ROUTE THE DOTTED-PATH DECISION NEITHER CAUSED NOR FIXED. +// `cfg.tree` where `cfg` is a module-level `data` value refuses at resolve with resolve_reason_unbound_symbol. +// Receipt, taken in a detached worktree so the two heads are the only variable: the SAME reason at +// 2bd1f0ded41 and at bd9d3d3bb5c. The cause is upstream of every arm that chooses a reading -- the head +// segment is not in the scope chain AT ALL, so it answers ScopeUnbound, and the declaration reading finds no +// candidate for the path `cfg.tree`. It is therefore a THIRD form, distinct from the two +// resolve_dotted_path_reading decides between, and closing it means making module-level value bindings +// answerable on the chain rather than adding an arm here. +// +// THE ROW IS WRITTEN AT THE REFUSAL, NOT AT THE CAPABILITY, so it cannot sit green while the thing it names +// stays broken, and it flips the moment a root-bound value becomes projectable. It is asserted BY REASON: +// a row satisfied by any refusal would stay green if the gate over-prohibited and broke this for a +// different cause, which is exactly the failure this control exists to catch. +// +// NEXT TRIGGER: a module-level value binding that the scope chain answers for. Until then the 3d repair's +// scope claim is "a namespace segment may not be projected through", and this row is what keeps it from +// being read as "only a lexical binder may have fields". +test fn fps_a_root_bound_data_value_is_not_yet_projectable_holds() -> Bool { + match fps_root_data_projection_source() { + Rejected { diagnostics: r } => r.head.reason == ^resolve_reason_unbound_symbol + Accepted { value: _, diagnostics: _ } => false + } +} + +// BOTH READINGS OF A DOTTED PATH, QUALIFIED TOGETHER. v2.compiler.resolve resolve_dotted_path_reading +// decides between a qualified DECLARATION NAME and a value FIELD PROJECTION from the head's ScopeBinding, +// and the two forms are qualified in one place because the defect was never in either arm -- it was in a +// discriminator that could not tell them apart. A pair of rows is what holds that: either one alone is +// satisfied by a resolver that always picks its own arm. +// TWO MODULES, AND A MULTI-SEGMENT MODULE NAME, because the native subject is neither same-module nor +// two-segment: it is `v2.std.live_tree.LiveTreeDisposition`, a FOUR-segment path into ANOTHER module. A +// first version of this row used `p.Box` inside module `p` and passed, which is why it is written out here +// -- a same-module two-segment path exercises a different lookup than a cross-module one, so that row was +// green about a property it never tested. The fixture now carries the subject's actual shape. +fn fps_two_module_assemble(provider: String, consumer: String) -> Outcome { + assemble_program_from_ingest( + ingest: Cons { + head: DagSourceReadWitness { + source: Medium { carried: provider, fidelity: Lossless }, + artifact: fps_provider_artifact, + compilation_unit: ^field_projection_provider_cu, + source_root: V2Tree + }, + tail: Cons { + head: DagSourceReadWitness { + source: Medium { carried: consumer, fidelity: Lossless }, + artifact: fps_artifact, + compilation_unit: ^field_projection_stages_cu, + source_root: V2Tree + }, + tail: Empty + } + }, + admission: Admission { subject: ResolutionSubject { name: Cons { head: ^p, tail: Empty } }, imports: Empty }, + lm: dag_language_model() + ) +} + +data fps_provider_artifact: Artifact = Artifact { + kind: SourceFile, id: ^fps_provider_artifact, file_path: "src/v2/pilot/fps_provider.dag" +} + +fn fps_qualified_declaration_path_source() -> Outcome { + fps_two_module_assemble( + provider: "module a.b.c\n\ntype Box {\n tree: Int\n}\n", + consumer: "module p\n\nimport a.b.c { Box }\n\nfn f(b: a.b.c.Box) -> Int {\n b.tree\n}\n" + ) +} + +fn fps_frame_binder_projection_source() -> Outcome { + fps_assemble(src: "module p\n\ntype Box {\n tree: Int\n}\n\nfn f(b: Box) -> Int {\n b.tree\n}\n") +} + +// (1) A QUALIFIED DECLARATION PATH RESOLVES. A four-segment cross-module path names a declaration the +// corpus carries, so the head segment being bound at root must NOT make the path a projection of a field off +// that head. NO NATIVE SUBJECT IS CLAIMED FOR THIS ROW: an earlier revision described it as the native +// refusal in miniature, and that attribution was falsified -- the eight's actual cause entry anchors a real +// two-edge projection off a fold-step binder, which resolution never sees. This row stands on the rule. +test fn fps_a_qualified_declaration_path_resolves_holds() -> Bool { + match fps_qualified_declaration_path_source() { + Accepted { value: _, diagnostics: _ } => true + Rejected { diagnostics: _ } => false + } +} + +// (2) AND A FRAME BINDER IS STILL PROJECTED THROUGH. `b.tree` off a parameter is the form the declaration +// reading must not swallow, and it is why the decision is BoundInFrame-first rather than +// declaration-path-first for every head: Section 13's chain-wins rule says the name the author bound +// lexically answers, and the absolute candidates are not consulted. +// +// THE PAIR IS THE EVIDENCE, NOT EITHER ROW. A resolver that read every dotted path as a declaration passes +// (1) and reds (2); one that read every dotted path as a projection passes (2) and reds (1). Only a real +// discriminator passes both, which is the discriminating red the single-arm controls could not supply. +test fn fps_a_frame_binder_is_still_projected_through_holds() -> Bool { + match fps_frame_binder_projection_source() { + Accepted { value: _, diagnostics: _ } => true + Rejected { diagnostics: _ } => false + } +} + +// THE RETRIEVAL JOIN, MEASURED THROUGH INFER AND NOT ONLY RESOLVE. The rows above establish that a +// cross-module declaration path RESOLVES; they say nothing about whether inference can then retrieve that +// declaration to read a field off it, because fps_resolves stops at the resolved tree. That retrieval is +// v2.compiler.infer infer_projection_receiver asking ResolvedTree.resolved_declarations for the receiver +// type's declaring path -- and resolved_declarations is filled from the resolved root of the module being +// inferred, not from a closure-wide index of the providers it imports. +// +// ONE VARIABLE. Both sources declare the same record and project the same field off a plain parameter; they +// differ only in whether the record is declared in the consuming module or in an imported one. The fold-step +// binder of the pinned subject is deliberately NOT used here: a supplied two-module ingest cannot resolve +// `List` or `fold` without ingesting their providers, and a plain parameter reaches the same join with +// nothing else moving. +fn fps_cross_module_projection_source() -> Outcome { + fps_two_module_assemble( + provider: "module a.b.c\n\ntype Leg {\n target: Int\n}\n", + consumer: "module p\n\nimport a.b.c { Leg }\n\nfn read(l: Leg) -> Int {\n l.target\n}\n" + ) +} + +fn fps_same_module_projection_source() -> Outcome { + fps_assemble(src: "module p\n\ntype Leg {\n target: Int\n}\n\nfn read(l: Leg) -> Int {\n l.target\n}\n") +} + +// (1) POSITIVE CONTROL. Without it a red on (2) proves nothing -- the field reader and the projection rule +// must work same-module before the cross-module retrieval is the question. +test fn fps_a_same_module_record_projection_infers_holds() -> Bool { + fps_infers(o: fps_same_module_projection_source()) +} + +// (2) THE SUBJECT. A red here beside a green (1) locates the defect at the retrieval of an imported record's +// declaration, and NOT at the projection rule, the field reader or resolution. +test fn fps_a_cross_module_record_projection_infers_holds() -> Bool { + fps_infers(o: fps_cross_module_projection_source()) +} + +// ASSERTED OVER THE WHOLE CHAIN, NOT THE HEAD. An infer refusal's head is infer_grounding_not_derived -- +// the grounding that could not be completed -- and the deciding cause sits in the TAIL. A first version of +// these rows read r.head.reason and failed for that reason alone, which is the same misattribution that cost +// this lane a wrong causal account: in one measured diagnostic the head reason appeared 209 times and the +// actual cause once. v2.std.diagnostic diagnostics_has_reason is the existing authority for asking the chain, +// so these rows ask it rather than minting a second reader. +// +// (3) A PROVIDER THAT DOES NOT RESOLVE LEAVES ITS DECLARATIONS UNAVAILABLE, AND THAT IS NOT "DECLARES NO +// FIELDS". This row exists because the arm it exercises would otherwise have no authorable red. The +// honesty split gave ReceiverDeclarationUnavailable its own reason, and the closure index then made the +// ordinary cross-module case SUCCEED -- so the only remaining way to reach the arm is a provider whose +// declarations never enter the index. v2.compiler.name_resolve closure_declarations_demand drops a root that +// fails to resolve, which is exactly that condition, and this fixture produces it with a provider carrying +// an unresolvable body beside the record the consumer projects. +// +// DESIGN section 4b IS WHY THE ROW IS WRITTEN THIS WAY RATHER THAN DELETED. An earlier version asserted that +// the cross-module case refuses as unavailable; the capability repair made it infer, so that row was +// obsoleted BY SUCCESS exactly as the imported-grounding probe in +// v2.test.claim.reference_evidence.declaration_reference_evidence was. Deleting it would leave the new arm +// with no executing evidence and no discriminating red -- a permanently unreachable arm cited as coverage, +// which is the decoration section 4b forbids. So the subject moves to the condition that still reaches it. +// +// IT ASSERTS BOTH DIRECTIONS. The unavailable reason must be present AND declares_no_fields must be absent, +// because the whole defect being repaired was a correct program being told its receiver declares no fields. +fn fps_unresolvable_provider_source() -> Outcome { + fps_two_module_assemble( + provider: "module a.b.c\n\ntype Leg {\n target: Int\n}\n\nfn broken() -> Int {\n no_such_name_anywhere\n}\n", + consumer: "module p\n\nimport a.b.c { Leg }\n\nfn read(l: Leg) -> Int {\n l.target\n}\n" + ) +} + +test fn fps_an_unresolvable_provider_leaves_its_declaration_unavailable_holds() -> Bool { + match fps_unresolvable_provider_source() { + Rejected { diagnostics: _ } => false + Accepted { value: resolved, diagnostics: _ } => + match infer(tree: resolved) { + Accepted { value: _, diagnostics: _ } => false + Rejected { diagnostics: r } => + diagnostics_has_reason(d: Some { diagnostics: r }, reason: ^infer_reason_projection_receiver_declaration_unavailable) + && !diagnostics_has_reason(d: Some { diagnostics: r }, reason: ^infer_reason_projection_receiver_declares_no_fields) + } + } +} + +// (4) AND A RECEIVER THAT GENUINELY DECLARES NO FIELDS STILL SAYS SO. The split is only honest if the OTHER +// arm kept its meaning: an established type that is not a declaration reference at all is a fact about the +// program and must refuse with declares_no_fields. Without this row the split could be satisfied by routing +// every projection refusal to the new reason, which would lose a real diagnosis instead of gaining one. +fn fps_scalar_receiver_source() -> Outcome { + fps_assemble(src: "module p\n\nfn read(n: Int) -> Int {\n n.target\n}\n") +} + +test fn fps_a_scalar_receiver_still_declares_no_fields_holds() -> Bool { + match fps_scalar_receiver_source() { + Rejected { diagnostics: _ } => false + Accepted { value: resolved, diagnostics: _ } => + match infer(tree: resolved) { + Accepted { value: _, diagnostics: _ } => false + Rejected { diagnostics: r } => + diagnostics_has_reason(d: Some { diagnostics: r }, reason: ^infer_reason_projection_receiver_declares_no_fields) + } + } +} + +// DISCRIMINATOR: IS THE DEFECT "CROSS-MODULE" OR "MORE THAN ONE ROOT"? Same-module record and projection as +// the passing control, assembled through the TWO-module ingest with an unrelated provider beside it. If this +// refuses, the subject is not cross-module retrieval at all -- it is that a multi-root ingest yields an index +// that answers for nothing, which is a different and simpler defect with a different repair. +fn fps_same_module_projection_in_two_root_ingest() -> Outcome { + fps_two_module_assemble( + provider: "module a.b.c\n\ntype Unused {\n n: Int\n}\n", + consumer: "module p\n\ntype Leg {\n target: Int\n}\n\nfn read(l: Leg) -> Int {\n l.target\n}\n" + ) +} + +test fn fps_a_multi_root_ingest_does_not_itself_break_retrieval_holds() -> Bool { + fps_infers(o: fps_same_module_projection_in_two_root_ingest()) +} diff --git a/src/v2/test/claim/fold_encoding_test.dag b/src/v2/test/claim/fold_encoding_test.dag index 7551c5a3059..7015710022b 100644 --- a/src/v2/test/claim/fold_encoding_test.dag +++ b/src/v2/test/claim/fold_encoding_test.dag @@ -1,5 +1,6 @@ module v2.test.claim.fold_encoding +import v2.std.anonymous_binder { is_fold_carrier_binder } import v2.compiler.reference_conservation_admission { conserved_normalize_of_text, no_explained_drops } import v2.extdeps.languages.dag { qualified_name_from_module_node } import v2.std.optional { Absent, Optional, Present, optional_absent, optional_present } @@ -102,6 +103,24 @@ type FeSlots { head: OccurrenceId } +// THE CARRIER IS NOT AN AUTHORED OPERAND, SO IT IS NOT CHECKED AS ONE. This conjunct read +// fe_atom_is(n: carrier, sym: ^acc) -- the step's own first binder -- which held while +// v2.compiler.fold_lowering spelled the persistent slot with operands.carrier, and which made the lowered +// tree hold two visible binders with one name (the slot and the step Arrow's freshly-bound formal). +// v2.compiler.resolve's value-binder admission refused the inner one. +// +// The slot is now minted by v2.std.anonymous_binder fresh_fold_carrier, so it has no authored occurrence +// and no authored spelling -- which is why it never appears in FeSlots beside init, collection and head. +// Those three ARE authored operands and this claim still holds each at its own minted occurrence. The +// carrier is instead asserted to BE a generated slot and NOT to be the authored binder: either half alone +// is satisfiable by accident, and together they are the separation the encoding now guarantees. +fn fe_carrier_is_a_generated_slot(n: Node) -> Bool { + match node_atom_identity_optional(node: n) { + Present { value: sym } => is_fold_carrier_binder(sym: sym) && !(sym == ^acc) + Absent => false + } +} + fn fe_slots(encoding: Node) -> Optional { match fold_recurrence_loop(encoding: encoding) { Absent => Absent @@ -122,7 +141,7 @@ fn fe_slots(encoding: Node) -> Optional { Absent => Absent Present { value: step } => if fe_atom_is(n: init, sym: ^seed) && fe_atom_is(n: collection, sym: ^xs) - && fe_atom_is(n: head, sym: ^fold) && fe_atom_is(n: carrier, sym: ^acc) + && fe_atom_is(n: head, sym: ^fold) && fe_carrier_is_a_generated_slot(n: carrier) && fe_is_arrow(n: step) { match fe_minted(n: init) { Absent => Absent diff --git a/src/v2/test/claim/fold_lowering_test.dag b/src/v2/test/claim/fold_lowering_test.dag index 57fbf78457a..414fa990b89 100644 --- a/src/v2/test/claim/fold_lowering_test.dag +++ b/src/v2/test/claim/fold_lowering_test.dag @@ -217,14 +217,26 @@ fn with_duplicate_carrier_edge(n: Node, binder: Node) -> Node { ) } -test fn lowered_loop_binds_carrier_binder() -> Bool { +// THE CARRIER IS A GENERATED SLOT, NOT THE AUTHORED BINDER, AND THIS ROW IS WHERE THAT IS ASSERTED. It +// read `sym == ^acc` -- the step's own first binder -- which was true while +// v2.compiler.fold_lowering spelled the Bind's binder and the carrier edge with +// operands.carrier. That made the lowered tree hold TWO VISIBLE BINDERS WITH ONE NAME, the persistent slot +// and the step Arrow's freshly-bound formal, and v2.compiler.resolve's value-binder admission refused the +// inner one. The slot now comes from v2.std.anonymous_binder fresh_fold_carrier. +// +// WHAT IT ASSERTS NOW IS THE SEPARATION ITSELF, in both directions, because either half alone is +// satisfiable by an accident: the carrier IS a fold-carrier binder (so the row fails if the mint is +// bypassed and some other symbol appears), and it is NOT the authored binder (so the row fails if the +// constructor goes back to borrowing `acc`). A row asserting only the first would still pass if the mint +// were keyed on the authored name. +test fn lowered_loop_carrier_is_a_generated_slot_not_the_authored_binder() -> Bool { match lowered_fixture_loop() { Absent => false Present { value: lp } => match carrier_binder_of(n: lp) { Found { target: binder } => match node_atom_identity_optional(node: binder) { - Present { value: sym } => sym == ^acc + Present { value: sym } => is_fold_carrier_binder(sym: sym) && !(sym == ^acc) Absent => false } Ambiguous => false diff --git a/src/v2/test/claim/manual/infer_algebra_ref_grounding_anchor.dag b/src/v2/test/claim/manual/infer_algebra_ref_grounding_anchor.dag index 44acdde69fd..5bd6ec6af97 100644 --- a/src/v2/test/claim/manual/infer_algebra_ref_grounding_anchor.dag +++ b/src/v2/test/claim/manual/infer_algebra_ref_grounding_anchor.dag @@ -1,4 +1,5 @@ module v2.test.manual.infer_algebra_ref_grounding_anchor +import v2.std.optional { optional_absent } import v2.std.witness { Holds, Violates, Witness } import v2.compiler.infer { algebra_ref_is_grounded, canonical_grounding_from_inferred_facts } @@ -55,7 +56,8 @@ fn anchor_inferred_facts_bare_atom_algebra() -> InferredFacts { non_increasing: [], strict: v2.std.cardinality.RankingComponent { measured: ^anchor_descent_dim_sym } } - } + }, + denotation: optional_absent() } } diff --git a/src/v2/test/claim/match_binder/match_binder_typing_test.dag b/src/v2/test/claim/match_binder/match_binder_typing_test.dag new file mode 100644 index 00000000000..43cb0b4f01f --- /dev/null +++ b/src/v2/test/claim/match_binder/match_binder_typing_test.dag @@ -0,0 +1,742 @@ +module v2.test.claim.match_binder.match_binder_typing + +import v2.compiler.resolve { ResolvedTree } +import v2.compiler.infer { infer, inferred_facts_resolved_type } +import v2.std.qualified_name { declaration_reference_path_optional, lexical_reference_label_optional, qualified_name_last_segment } +import v2.std.witness { Holds, Violates } +import v2.compiler.name_resolve { Admission, ResolutionSubject } +import v2.compiler.program_assembly { assemble_program_from_ingest } +import v2.compiler.source_authority { DagSourceReadWitness } +import v2.extdeps.languages.dag { dag_language_model } +import extdeps.communication.medium { Lossless, Medium } +import std.algebra { Cons, Empty } +import v2.std.cross_tree.import_model { V2Tree } +import v2.std.artifact { Artifact, SourceFile } +import v2.std.diagnostic { Accepted, NonEmptyDiagnostics, None, Outcome, Rejected, Some, node_locus } +import v2.std.algebra { contains, list_map } +import v2.std.node { + Atom, + ComputationNode, + Conj, + Disj, + Edge, + Instantiation, + Match, + Authored, + MatchArmBodyEdge, + MatchArmPatternEdge, + Node, + Positional, + Symbol, + TypeNode, + core_edge_label +} +import v2.std.node_query { binder_node, construct_node, field_projection_optional } +import v2.std.symbol_index { + SymbolIndex, + empty_symbol_index, + symbol_index_insert, + symbol_index_mark_declared_type_params +} +import v2.std.qualified_name { QualifiedName, declaration_reference_node } +import v2.compiler.infer { + infer_descent_witness_for_node, + infer_match_coproduct, + inferred_facts_from_derived_type, + inferred_facts_grounding_derived, + inferred_facts_not_derived +} +import v2.compiler.inferred_tree { InferredFacts, InferredFactsEntry } +import v2.std.algebra { list_append } +import std.occurrence_identity { OccurrenceSynthetic } +import v2.std.collection { List, empty_map } +import v2.std.integer { Int } +import v2.std.optional { Absent, Optional, Present, optional_absent, optional_present } +import v2.std.text { String } + +data mbp_artifact: Artifact = Artifact { + kind: SourceFile, + id: ^match_binder_probe_artifact, + file_path: "src/v2/pilot/match_binder_probe_pilot.dag" +} + +fn mbp_assemble(src: String) -> Outcome { + assemble_program_from_ingest( + ingest: Cons { + head: DagSourceReadWitness { + source: Medium { carried: src, fidelity: Lossless }, + artifact: mbp_artifact, + compilation_unit: ^match_binder_probe_cu, + source_root: V2Tree + }, + tail: Empty + }, + admission: Admission { subject: ResolutionSubject { name: Cons { head: ^p, tail: Empty } }, imports: Empty }, + lm: dag_language_model() + ) +} + +fn mbp_reasons(r: NonEmptyDiagnostics) -> List { + Cons { head: r.head.reason, tail: list_map(xs: r.tail, f: fn(d) { d.reason }) } +} + +fn mbp_last(xs: List) -> Symbol { + fold(xs, init: ^mbp_no_reason, f: fn(acc, x) { x }) +} + +fn mbp_find_match(n: Node) -> Optional { + match n.kind { + ComputationNode { behavior: Match } => Present { value: n } + _ => fold(n.children, init: Absent, f: fn(acc, e) { + match acc { + Present { value: _ } => acc + Absent => mbp_find_match(n: e.target) + } + }) + } +} + +fn mbp_find_atom(n: Node, id: Symbol) -> Optional { + match n.kind { + TypeNode { connective: Atom { identity: s } } => if s == id { optional_present(value: n) } else { optional_absent() } + _ => fold(n.children, init: Absent, f: fn(acc, e) { + match acc { + Present { value: _ } => acc + Absent => mbp_find_atom(n: e.target, id: id) + } + }) + } +} + +fn mbp_type_name(t: Node) -> Symbol { + match declaration_reference_path_optional(node: t) { + Present { value: path } => + match qualified_name_last_segment(qn: path) { + Present { value: s } => s + Absent => ^mbp_empty_path + } + Absent => + match t.kind { + TypeNode { connective: Atom { identity: s } } => s + _ => ^mbp_type_not_named + } + } +} + +// THE ARM BODY'S PROJECTION OFF THE BINDER: the field projection whose base reads `artifact` and whose +// field is `tree`. Picked by its route, not by spelling: the pattern's own binder is a +// lexical reference labelled `artifact` too, and the first atom spelled `artifact` is the marker's payload, +// which carries no facts at all. +fn mbp_binder_projection(n: Node) -> Optional { + match field_projection_optional(n: n) { + Present { value: proj } => + if (proj.field == ^tree) && (mbp_binder_name_of(n: proj.base) == optional_present(value: ^artifact)) { + optional_present(value: n) + } else { + mbp_binder_projection_in_children(n: n) + } + Absent => mbp_binder_projection_in_children(n: n) + } +} + +// The name a projection base reads: a lexical reference's label, or the canonical atom a lexically bound +// projection head keeps (v2.compiler.resolve resolve_projection_base -- its declared frontier is that the +// projection route carries no lexical entries, so a Lexical head stays the atom). +fn mbp_binder_name_of(n: Node) -> Optional { + match lexical_reference_label_optional(node: n) { + Present { value: label } => optional_present(value: label) + Absent => + match n.kind { + TypeNode { connective: Atom { identity: id } } => optional_present(value: id) + TypeNode { connective: _ } => optional_absent() + ComputationNode { behavior: _ } => optional_absent() + } + } +} + +fn mbp_binder_projection_in_children(n: Node) -> Optional { + fold(n.children, init: Absent, f: fn(acc, e) { + match acc { + Present { value: _ } => acc + Absent => mbp_binder_projection(n: e.target) + } + }) +} + +fn mbp_binder_projection_base(n: Node) -> Optional { + match mbp_binder_projection(n: n) { + Absent => optional_absent() + Present { value: proj_node } => + match field_projection_optional(n: proj_node) { + Present { value: proj } => optional_present(value: proj.base) + Absent => optional_absent() + } + } +} + +// A REASON REPORTED AT A PICKED NODE'S OWN LOCUS, on an accepted inference. Matching the reason alone would +// be satisfied by the same reason reported anywhere in the module. +fn mbp_reports_at(src: String, reason: Symbol, pick: fn(Node) -> Optional) -> Bool { + match mbp_assemble(src: src) { + Rejected { diagnostics: _ } => false + Accepted { value: resolved, diagnostics: _ } => + match pick(resolved.root) { + Absent => false + Present { value: at } => + match infer(tree: resolved) { + Rejected { diagnostics: _ } => false + Accepted { value: _, diagnostics: d } => + match d { + None => false + Some { diagnostics: r } => + ((r.head.reason == reason) && (r.head.at == node_locus(node: at))) + || contains( + xs: list_map(xs: r.tail, f: fn(x) { (x.reason == reason) && (x.at == node_locus(node: at)) }), + item: true, + eq: fn(a, b) { a == b } + ) + } + } + } + } +} + +fn mbp_fact_of(src: String, pick: fn(Node) -> Optional) -> Symbol { + match mbp_assemble(src: src) { + Rejected { diagnostics: r } => r.head.reason + Accepted { value: resolved, diagnostics: _ } => + match infer(tree: resolved) { + Rejected { diagnostics: r } => mbp_last(xs: mbp_reasons(r: r)) + Accepted { value: inferred, diagnostics: _ } => + match pick(resolved.root) { + Absent => ^mbp_subject_absent + Present { value: subject } => + match inferred.facts.lookup(subject) { + Absent => ^mbp_no_facts + Present { value: facts } => + match inferred_facts_resolved_type(facts: facts) { + Violates { diagnostic: _ } => ^mbp_underived + Holds { value: t } => mbp_type_name(t: t) + } + } + } + } + } +} + +fn mbp_all_reasons(src: String) -> List { + match mbp_assemble(src: src) { + Rejected { diagnostics: r } => mbp_reasons(r: r) + Accepted { value: resolved, diagnostics: _ } => + match infer(tree: resolved) { + Rejected { diagnostics: r } => mbp_reasons(r: r) + Accepted { value: _, diagnostics: d } => + match d { + None => [^infers] + Some { diagnostics: r } => Cons { head: ^infers, tail: mbp_reasons(r: r) } + } + } + } +} + +fn mbp_reports(src: String, reason: Symbol) -> Bool { + contains(xs: mbp_all_reasons(src: src), item: reason, eq: fn(a, b) { a == b }) +} + +fn mbp_infers(src: String) -> Bool { + match mbp_all_reasons(src: src) { + Cons { head: h, tail: _ } => h == ^infers + Empty => false + } +} + +// THE SEVEN'S PAYLOAD, DECLARED AS THE CORPUS DECLARES IT. The fixture assembles one module, so it +// cannot import v2.std.diagnostic; it restates Outcome with the same variants, fields and binder, +// and a ParseArtifact record whose fields carry v2.compiler.parse ParseArtifact's names. What the +// claims below exercise is exactly the operator's subject: a variant field of a GENERIC coproduct +// instantiated at a concrete RECORD type, bound by an arm pattern and projected in the arm body. +data mbt_decls: String = "module p\n\ntype ParseTree {\n root: Int\n}\n\ntype ParseArtifact {\n tree: ParseTree\n span_index: Int\n alloc: Int\n}\n\ntype NonEmptyDiagnostics {\n head: Int\n}\n\ntype Diagnostics\n = None\n | Some { diagnostics: NonEmptyDiagnostics }\n\ntype Outcome\n = Accepted { value: T, diagnostics: Diagnostics }\n | Rejected { diagnostics: NonEmptyDiagnostics }\n\n" + +fn mbt_with_match(scrutinee_type: String, arms: String) -> String { + mbt_decls + "fn g(h: " + scrutinee_type + ", fallback: ParseArtifact) -> ParseTree {\n match h {\n" + arms + " }\n}\n" +} + +data mbt_arms: String = " Rejected { diagnostics: r } => fallback.tree\n Accepted { value: artifact, diagnostics: d } => artifact.tree\n" + +data mbt_positive_src: String = mbt_with_match(scrutinee_type: "Outcome", arms: mbt_arms) + +// (1) THE BINDER IS TYPED ParseArtifact FROM Outcome's Accepted.value -- the argument +// the scrutinee's type supplies at the declaration's binder T, not T itself and not the field's +// authored spelling. +// DECLARED RUNG DROP (gunbc.rung_drop match_arm_binder_typing_lost_to_lexical_carrier): the binder is NOT +// typed today, and this row pins exactly HOW it is not, so it flips back -- to `== ^ParseArtifact` -- when +// match-arm binder typing reaches the lexical-binding carrier (N7-3). The binder's use in the arm body is the +// lexical reference resolve minted (v2.compiler.resolve resolve_lexical_reference), whose recorded binding +// declares no type, so infer leaves it underived (v2.compiler.infer infer_lexical_reference_facts), and the +// arm body `artifact.tree` is reported at ITS OWN LOCUS as infer_match_arm_body_type_underived. Both halves are +// required: a program refused for an unrelated cause, or an underived binder reported somewhere else, reds it. +test fn mbt_binder_is_not_yet_typed_and_its_arm_body_is_reported_holds() -> Bool { + (mbp_fact_of(src: mbt_positive_src, pick: fn(root) { mbp_binder_projection_base(n: root) }) == ^mbp_underived) + && mbp_reports_at(src: mbt_positive_src, reason: ^infer_match_arm_body_type_underived, pick: fn(root) { mbp_binder_projection(n: root) }) +} + +// AND THE MATCH IS TYPED BY ITS ARMS: each arm projects `tree` off a ParseArtifact -- one bound by +// the pattern, one a parameter -- and the two agree. Both arms are projections on purpose: a +// projection is typed by the field's declared type node as the index holds it +// (v2.compiler.infer infer_field_projection_facts), which is not the resolved reference a parameter +// annotated `ParseTree` carries, so a projection arm beside a parameter arm compares two spellings of +// one type. That is the projection's typing boundary, reported to its owning lane, not this match's. +// DECLARED RUNG DROP, as above: the match is accepted at the frontier and NOT typed, because the arm that +// projects off the binder is underived at the binder projection's locus. Flips back to `== ^ParseTree` +// with the same trigger. +test fn mbt_match_is_not_yet_typed_at_the_binder_arm_holds() -> Bool { + (mbp_fact_of(src: mbt_positive_src, pick: fn(root) { mbp_find_match(n: root) }) == ^mbp_underived) + && mbp_reports_at(src: mbt_positive_src, reason: ^infer_match_arm_body_type_underived, pick: fn(root) { mbp_binder_projection(n: root) }) +} + +// REVERSED BY RULING: A BINDER MAY NOT HIDE A VISIBLE VALUE BINDING, SO THIS REFUSES. This row asserted +// the opposite -- that an arm binder spelled like an enclosing parameter SHADOWS it and the match still +// types -- which was the right claim when the defect it guarded was a scope walk that typed such a binder +// as the parameter. The operator ruling of 2026-09-30 forbids value-name shadowing outright, so the +// program this row was written over is no longer a legal one and the row is reversed rather than deleted +// (DESIGN section 4b(4): an expecting-red probe that flips becomes the regression control, it does not +// retire). Here `artifact` is an Int parameter of `g` and also the Accepted arm's binder. +// +// WHAT IT NOW ESTABLISHES, AND WHY IT STAYS ON THE SOURCE ROUTE. v2.compiler.resolve admits every value +// binder through one gate (admit_value_binders), and the cheap control for the rule itself lives at that +// gate's own interface (v2.test.claim.binder_admission.callable_binder_slice +// cbs_a_binder_hiding_an_enclosing_binder_refuses, 77 eval steps). This row is the one that says the rule +// is reached by AUTHORED SOURCE through the real front end -- the route, not the shape -- which is the +// pairing obligation the supplied row cannot discharge for itself. +// +// The refusal is asserted BY REASON, not merely as "did not type": a row satisfied by any refusal would +// stay green if the shadowing gate were deleted and the module broke for an unrelated cause. +test fn mbt_a_binder_hiding_a_same_named_parameter_refuses_holds() -> Bool { + mbp_reports( + src: mbt_decls + "fn g(artifact: Int, h: Outcome, fallback: ParseArtifact) -> ParseTree {\n match h {\n" + mbt_arms + " }\n}\n", + reason: ^resolve_reason_binder_hides_visible_value + ) +} + +// (2) REFUSALS, EACH DISCRIMINATING: the same match with one pattern edit, so the positive control +// above is the other half of every pair. +test fn mbt_a_variant_the_coproduct_does_not_declare_refuses_holds() -> Bool { + mbt_supplied_refuses_with( + o: mbt_supplied_match( + scrutinee_type: mbt_outcome_of(args: [mbt_ref(segs: [^p, ^ParseArtifact])]), + arms: list_append( + left: mbt_supplied_arms(), + right: [mbt_arm(pattern: mbt_variant_pattern(tag: ^None, fields: Empty), body: mbt_body_a())] + ) + ), + reason: ^infer_match_pattern_variant_not_declared + ) +} + +test fn mbt_a_field_the_variant_does_not_declare_refuses_holds() -> Bool { + mbt_supplied_refuses_with( + o: mbt_supplied_match( + scrutinee_type: mbt_outcome_of(args: [mbt_ref(segs: [^p, ^ParseArtifact])]), + arms: [ + mbt_rejected_arm(body: mbt_body_a()), + mbt_arm( + pattern: mbt_variant_pattern(tag: ^Accepted, fields: [ + mbt_named(name: ^tree, target: mbt_binder(name: ^artifact)), + mbt_named(name: ^diagnostics, target: mbt_binder(name: ^d)) + ]), + body: mbt_body_b() + ) + ] + ), + reason: ^infer_match_pattern_field_not_declared + ) +} + +test fn mbt_too_many_type_arguments_refuses_holds() -> Bool { + mbt_supplied_refuses_with( + o: mbt_supplied_match( + scrutinee_type: mbt_outcome_of(args: [mbt_ref(segs: [^p, ^ParseArtifact]), mbt_parse_tree_type()]), + arms: mbt_supplied_arms() + ), + reason: ^infer_match_type_argument_arity_mismatch + ) +} + +test fn mbt_no_type_arguments_to_a_generic_refuses_holds() -> Bool { + mbt_supplied_refuses_with( + o: mbt_supplied_match( + scrutinee_type: mbt_ref(segs: [^p, ^Outcome]), + arms: mbt_supplied_arms() + ), + reason: ^infer_match_type_argument_arity_mismatch + ) +} + +test fn mbt_a_missing_variant_refuses_as_non_exhaustive_holds() -> Bool { + mbt_supplied_refuses_with( + o: mbt_supplied_match( + scrutinee_type: mbt_outcome_of(args: [mbt_ref(segs: [^p, ^ParseArtifact])]), + arms: [mbt_accepted_arm(body: mbt_body_b())] + ), + reason: ^infer_match_non_exhaustive + ) +} + +test fn mbt_a_record_scrutinee_refuses_holds() -> Bool { + mbt_supplied_refuses_with( + o: mbt_supplied_match( + scrutinee_type: mbt_ref(segs: [^p, ^ParseArtifact]), + arms: mbt_supplied_arms() + ), + reason: ^infer_match_scrutinee_not_coproduct + ) +} + +// (3) THE SEVEN'S SHAPE: the scrutinee is a CALL returning Outcome, and the arm body +// rebuilds an Outcome from the projection. What infer establishes about it is reported as it is. +data mbt_seven_src: String = mbt_decls + "fn parse_module_prepared(tokens: Int) -> Outcome {\n Rejected { diagnostics: NonEmptyDiagnostics { head: tokens } }\n}\n\nfn g_tokenize_parse(text: Int) -> Outcome {\n match parse_module_prepared(tokens: text) {\n Rejected { diagnostics: r } => Rejected { diagnostics: r }\n Accepted { value: artifact, diagnostics: d } =>\n Accepted { value: artifact.tree, diagnostics: d }\n }\n}\n" + +// THE SEVEN'S SHAPE IS ACCEPTED AT THE FRONTIER, AND COUNTED THERE. On this stack the call's declared +// return is not derived (an application's result is derived only for kernel and Bool returns), so the +// scrutinee is untyped and the match cannot be checked against Outcome's variants. It is accepted -- +// refusing would convict the program for a route this stage does not reach -- and the acceptance +// carries infer_match_scrutinee_type_underived at the scrutinee, so the untyped match is a counted +// site and not a silent green. When the call's return derives, this claim goes red and the positive +// controls above become the seven's own. +// +// THIS ROW STAYS ON THE SOURCE ROUTE, AND THE PAIRING OBLIGATION IS WHY. It was converted to the +// supplied boundary and the conversion was withdrawn: it is the ONLY row that establishes that an +// APPLICATION scrutinee really reaches infer's underived-scrutinee arm, and a supplied entry carrying +// GroundingNotDerived asserts what this file chose rather than what a call's return derivation leaves +// behind. Supplying it would have removed the last execution of the real path into that arm, which +// DESIGN section 3 forbids outright -- the two typed-binder rows above inhabit the DERIVED path and +// cannot stand in for it. It is over the new-witness margin and reported as such, because the +// alternative is a cheap suite that establishes nothing about the arm it names. +// +// THE SUPPLIED ROW BESIDE IT reads the same arm's RESULT CONTRACT at one interface, so the two are a +// pair rather than a duplicate: this row says the arm is reached, that one says what the arm returns. +test fn mbt_the_sevens_call_scrutinee_is_a_counted_frontier_holds() -> Bool { + mbp_infers(src: mbt_seven_src) + && mbp_reports(src: mbt_seven_src, reason: ^infer_match_scrutinee_type_underived) +} + +// WHAT THE FRONTIER ARM RETURNS, at infer_match_coproduct's own interface: an underived scrutinee is +// ACCEPTED rather than convicted, and the acceptance CARRIES the located advisory. Both halves are +// properties of the returned Outcome, so they are read from it directly instead of through a whole +// module's inference. Its inhabitance is the row above. +test fn mbt_the_frontier_arm_accepts_and_reports_the_underived_scrutinee_holds() -> Bool { + mbt_supplied_accepts_reporting( + o: infer_match_coproduct( + node: mbt_match_node(scrutinee: mbt_scrutinee(), arms: mbt_supplied_arms()), + partials: Empty, + entries: list_append(left: mbt_underived_scrutinee_entry(), right: mbt_typed_bodies()), + resolved: mbt_resolved() + ), + reason: ^infer_match_scrutinee_type_underived + ) +} + +fn mbt_underived_scrutinee_entry() -> List { + match inferred_facts_not_derived( + node: mbt_scrutinee(), + descent: infer_descent_witness_for_node(n: mbt_scrutinee()) + ) { + Rejected { diagnostics: _ } => Empty + Accepted { value: facts, diagnostics: _ } => [InferredFactsEntry { node: mbt_scrutinee(), facts: facts }] + } +} + +// AN ARM BODY WHOSE TYPE IS NOT DERIVED IS COUNTED, NOT GUESSED. (Its binder conjunct is under the same +// declared rung drop as the rows above: it asserts the binder's use is underived today, and flips back to +// `== ^ParseArtifact` with them.) The scrutinee here IS typed, so the +// patterns are checked and the binder is typed; the bodies rebuild an Outcome from a constructor, +// which this stage does not type, so the match is accepted at the frontier with one located +// infer_match_arm_body_type_underived per such body -- and the binder beneath it is still typed. +data mbt_constructor_body_src: String = mbt_with_match(scrutinee_type: "Outcome", arms: " Rejected { diagnostics: r } => Rejected { diagnostics: r }\n Accepted { value: artifact, diagnostics: d } => Accepted { value: artifact.tree, diagnostics: d }\n") + +test fn mbt_an_underived_arm_body_is_a_counted_frontier_holds() -> Bool { + mbp_infers(src: mbt_constructor_body_src) + && mbp_reports(src: mbt_constructor_body_src, reason: ^infer_match_arm_body_type_underived) + && (mbp_fact_of(src: mbt_constructor_body_src, pick: fn(root) { mbp_binder_projection_base(n: root) }) == ^mbp_underived) +} + +// THE MATCH-TYPING BOUNDARY, READ AT ONE INTERFACE WITH SUPPLIED INPUTS. Every refusal row below used +// to assemble a twenty-five line module, resolve it and run the whole of `infer` in order to inspect +// what ONE function answers for ONE match node. Re-derive the cost with claim_batch over this entry +// rather than trusting a figure here: what it shows is the diagnostic DESIGN section 3 names, that the +// row's cost did not move when its assertion changed, because the assertion was never the expense. +// +// THE INTERFACE IS v2.compiler.infer infer_match_coproduct, which is where every one of these +// judgments is actually decided: the variant-not-declared, field-not-declared, arity-mismatch, +// non-exhaustive and scrutinee-not-coproduct refusals, and the two frontier acceptances. Its inputs +// are a match Node, the partials roster, the facts entries its operands carry, and the ResolvedTree +// whose symbol_index holds the coproduct's declaration. All four are CONSTRUCTED here -- and +// constructed by the production constructors, not by hand-shaped records: v2.std.qualified_name +// declaration_reference_node builds every reference, v2.std.node_query construct_node builds every +// arm pattern, and v2.std.symbol_index symbol_index_insert and symbol_index_mark_declared_type_params +// build the index. So a change to any of those encodings reaches these rows the same way it reaches +// resolve. +// +// THE PAIRING OBLIGATION IS DISCHARGED BY THE THREE REAL-PATH ROWS ABOVE, which stay on the source +// route and are the last execution of it: mbt_binder_is_typed_parse_artifact_from_accepted_value and +// mbt_match_is_typed_parse_tree assemble, resolve and infer authored text and read the facts the real +// producer emitted, so they are the claim that this shape is one the front end really builds -- a +// supplied input here is a hypothesis about a boundary those rows show is inhabited. Deleting resolve's +// match lowering, or infer's entry production, makes them fail; it would not make any row below fail, +// which is exactly why they are not replaced. +fn mbt_qn(segs: List) -> QualifiedName { + segs +} + +fn mbt_ref(segs: List) -> Node { + declaration_reference_node(qn: mbt_qn(segs: segs), occurrence_id: OccurrenceSynthetic) +} + +fn mbt_atom(s: Symbol) -> Node { + Node { kind: TypeNode { connective: Atom { identity: s } }, children: [], occurrence_id: OccurrenceSynthetic } +} + +fn mbt_conj(fields: List) -> Node { + Node { kind: TypeNode { connective: Conj }, children: fields, occurrence_id: OccurrenceSynthetic } +} + +// A DECLARED field, as lowering writes one: the Authored edge targets a binder node carrying the type +// (v2.std.node_query binder_node), which is what declared_field_from_edge reads. Pattern fields stay +// mbt_named: a pattern binds an atom, it does not declare a field. +fn mbt_field(name: Symbol, type_node: Node) -> Edge { + Edge { label: Authored { name: name }, target: binder_node(type_node: type_node, default: optional_absent()) } +} + +fn mbt_named(name: Symbol, target: Node) -> Edge { + Edge { label: Authored { name: name }, target: target } +} + +fn mbt_pos(target: Node) -> Edge { + Edge { label: Positional, target: target } +} + +// `Outcome = Accepted { value: T, diagnostics: Diagnostics } | Rejected { diagnostics: NonEmptyDiagnostics }` +// as the index holds it: a Disj whose Named edges are the variant tags and whose targets are the +// payload Conjs. `value` is declared as the type PARAMETER, which is what makes the instantiation +// visible at all -- a payload whose fields were all concrete would type the binder without ever +// consulting the scrutinee's type argument. +fn mbt_outcome_declaration() -> Node { + Node { + kind: TypeNode { connective: Disj }, + children: [ + mbt_named( + name: ^Accepted, + target: mbt_conj(fields: [ + mbt_field(name: ^value, type_node: mbt_atom(s: ^T)), + mbt_field(name: ^diagnostics, type_node: mbt_ref(segs: [^p, ^Diagnostics])) + ]) + ), + mbt_named( + name: ^Rejected, + target: mbt_conj(fields: [ + mbt_field(name: ^diagnostics, type_node: mbt_ref(segs: [^p, ^NonEmptyDiagnostics])) + ]) + ) + ], + occurrence_id: OccurrenceSynthetic + } +} + +// `type ParseArtifact { tree: ParseTree ... }` -- a record, so its declaration is a Conj and not a +// Disj. It is in the index for two reasons: it is the type argument the binder must receive, and it is +// the non-coproduct scrutinee the refusal row needs. +fn mbt_parse_artifact_declaration() -> Node { + mbt_conj(fields: [mbt_field(name: ^tree, type_node: mbt_ref(segs: [^p, ^ParseTree]))]) +} + +fn mbt_index() -> SymbolIndex { + symbol_index_mark_declared_type_params( + index: symbol_index_insert( + index: symbol_index_insert( + index: empty_symbol_index(), + qualified_path: mbt_qn(segs: [^p, ^Outcome]), + resolved: mbt_outcome_declaration() + ), + qualified_path: mbt_qn(segs: [^p, ^ParseArtifact]), + resolved: mbt_parse_artifact_declaration() + ), + qualified_path: mbt_qn(segs: [^p, ^Outcome]), + params: [^T] + ) +} + +// THE ROOT IS A LEAF, DELIBERATELY. infer_branch_operand_resolved_type_in_tree consults +// infer_parameter_type_in_scope over resolved.root before falling back to the operand's own facts, and +// this slice's subject is what the SUPPLIED facts establish; a root that bound the scrutinee name would +// answer from the scope walk instead and the entries below would stop being the discriminator. +fn mbt_resolved() -> ResolvedTree { + ResolvedTree { + root: mbt_atom(s: ^mbt_unused_root), + symbol_index: mbt_index(), + resolved_declarations: empty_symbol_index(), + lexical_bindings: empty_map() + } +} + +fn mbt_instantiation(head: Node, args: List) -> Node { + Node { + kind: TypeNode { connective: Instantiation }, + children: list_append(left: [mbt_pos(target: head)], right: list_map(xs: args, f: fn(a) { mbt_pos(target: a) })), + occurrence_id: OccurrenceSynthetic + } +} + +fn mbt_outcome_of(args: List) -> Node { + mbt_instantiation(head: mbt_ref(segs: [^p, ^Outcome]), args: args) +} + +fn mbt_scrutinee() -> Node { + mbt_atom(s: ^mbt_scrutinee_operand) +} + +// A DERIVED TYPE FOR A SUPPLIED NODE, minted by infer's OWN constructor rather than by a hand-built +// InferredFacts record: a record assembled here would be this module's guess at what a derived +// grounding looks like, and the guess would keep passing after the real shape moved. +fn mbt_facts_typed(node: Node, ty: Node) -> Optional { + match inferred_facts_from_derived_type( + node: node, + derived_type: ty, + descent: infer_descent_witness_for_node(n: node) + ) { + Accepted { value: facts, diagnostics: _ } => optional_present(value: facts) + Rejected { diagnostics: _ } => optional_absent() + } +} + +fn mbt_entry(node: Node, ty: Node) -> List { + match mbt_facts_typed(node: node, ty: ty) { + Absent => Empty + Present { value: facts } => [InferredFactsEntry { node: node, facts: facts }] + } +} + +fn mbt_arm(pattern: Node, body: Node) -> Node { + mbt_conj(fields: [ + Edge { label: core_edge_label(marker: MatchArmPatternEdge), target: pattern }, + Edge { label: core_edge_label(marker: MatchArmBodyEdge), target: body } + ]) +} + +// A CONSTRUCTOR PATTERN THROUGH THE CONSTRUCT ENCODING'S OWN BUILDER. infer_coproduct_arm_pattern reads +// the tag from construct_tag_path_optional, so a pattern built any other way would be read as unread -- +// which is the exact defect gunbc#12714's annotation above records, reached from the other side. +fn mbt_variant_pattern(tag: Symbol, fields: List) -> Node { + construct_node( + reference: mbt_ref(segs: [^p, tag]), + field_edges: fields, + source: mbt_atom(s: ^mbt_pattern_site) + ) +} + +fn mbt_binder(name: Symbol) -> Node { + mbt_atom(s: name) +} + +fn mbt_accepted_arm(body: Node) -> Node { + mbt_arm( + pattern: mbt_variant_pattern(tag: ^Accepted, fields: [ + mbt_named(name: ^value, target: mbt_binder(name: ^artifact)), + mbt_named(name: ^diagnostics, target: mbt_binder(name: ^d)) + ]), + body: body + ) +} + +fn mbt_rejected_arm(body: Node) -> Node { + mbt_arm( + pattern: mbt_variant_pattern(tag: ^Rejected, fields: [ + mbt_named(name: ^diagnostics, target: mbt_binder(name: ^r)) + ]), + body: body + ) +} + +fn mbt_match_node(scrutinee: Node, arms: List) -> Node { + Node { + kind: ComputationNode { behavior: Match }, + children: list_append(left: [mbt_pos(target: scrutinee)], right: list_map(xs: arms, f: fn(a) { mbt_pos(target: a) })), + occurrence_id: OccurrenceSynthetic + } +} + +// THE BODIES ARE TYPED TOO, so a refusal row below refuses for the reason it names rather than for a +// body the stage could not type. `mbt_supplied_bodies` is the pair of typed bodies both well-formed +// arms share, and the frontier row deliberately omits them. +fn mbt_body_a() -> Node { + mbt_atom(s: ^mbt_body_a) +} + +fn mbt_body_b() -> Node { + mbt_atom(s: ^mbt_body_b) +} + +fn mbt_parse_tree_type() -> Node { + mbt_ref(segs: [^p, ^ParseTree]) +} + +fn mbt_typed_bodies() -> List { + list_append( + left: mbt_entry(node: mbt_body_a(), ty: mbt_parse_tree_type()), + right: mbt_entry(node: mbt_body_b(), ty: mbt_parse_tree_type()) + ) +} + +fn mbt_entries_for(scrutinee_type: Node) -> List { + list_append( + left: mbt_entry(node: mbt_scrutinee(), ty: scrutinee_type), + right: mbt_typed_bodies() + ) +} + +fn mbt_supplied_arms() -> List { + [mbt_rejected_arm(body: mbt_body_a()), mbt_accepted_arm(body: mbt_body_b())] +} + +fn mbt_supplied_match(scrutinee_type: Node, arms: List) -> Outcome { + infer_match_coproduct( + node: mbt_match_node(scrutinee: mbt_scrutinee(), arms: arms), + partials: Empty, + entries: mbt_entries_for(scrutinee_type: scrutinee_type), + resolved: mbt_resolved() + ) +} + +fn mbt_supplied_refuses_with(o: Outcome, reason: Symbol) -> Bool { + match o { + Accepted { value: _, diagnostics: _ } => false + Rejected { diagnostics: r } => contains(xs: mbp_reasons(r: r), item: reason, eq: fn(a, b) { a == b }) + } +} + +fn mbt_supplied_accepts_reporting(o: Outcome, reason: Symbol) -> Bool { + match o { + Rejected { diagnostics: _ } => false + Accepted { value: _, diagnostics: d } => + match d { + None => false + Some { diagnostics: r } => contains(xs: mbp_reasons(r: r), item: reason, eq: fn(a, b) { a == b }) + } + } +} + +// THE POSITIVE CONTROL FOR THE SUPPLIED SLICE, and it is what stops every refusal row in this file from +// passing because the fixture is malformed. A match over Outcome with both variants +// covered and both bodies typed must be accepted AND GROUNDED -- grounded rather than merely accepted, +// because the frontier arm also accepts, so "Accepted" alone would be satisfied by a fixture whose +// scrutinee type never read at all. If the index, the declaration, the arm encoding, the entries or the +// instantiation were wrong, this row goes red and every refusal above stops being evidence about the +// judgment it names. +test fn mbt_the_supplied_match_is_accepted_and_typed_holds() -> Bool { + match mbt_supplied_match( + scrutinee_type: mbt_outcome_of(args: [mbt_ref(segs: [^p, ^ParseArtifact])]), + arms: mbt_supplied_arms() + ) { + Rejected { diagnostics: _ } => false + Accepted { value: facts, diagnostics: _ } => inferred_facts_grounding_derived(facts: facts) + } +} diff --git a/src/v2/test/claim/namespace_xl0/cross_module_reference_resolution_test.dag b/src/v2/test/claim/namespace_xl0/cross_module_reference_resolution_test.dag index f3308cdcba4..4cdb6fe488a 100644 --- a/src/v2/test/claim/namespace_xl0/cross_module_reference_resolution_test.dag +++ b/src/v2/test/claim/namespace_xl0/cross_module_reference_resolution_test.dag @@ -1,6 +1,9 @@ module v2.test.claim.namespace_xl0.cross_module_reference_resolution import v2.compiler.resolve { ResolvedTree } +import v2.compiler.infer { infer } +import v2.std.node_query { field_projection_optional } +import v2.std.node { Node } import v2.compiler.name_resolve { Admission, ResolutionSubject, @@ -502,13 +505,34 @@ test fn a_cross_module_reference_to_a_data_declaration_resolves_to_its_declaring // `fn get(r: Rec) -> Bool { r.v }`, which needs BOTH the declaration graft (so Rec.v is in the // containment tree) AND the unique-on-chain projection, and is owned by those lanes, not enrolled // here as a today-row that could be retired by half of its trigger (DESIGN section 4b(3)). -test fn a_receiver_with_no_such_child_never_accepts_holds() -> Bool { - match xl0r_resolved_field_access_consumer() { - Accepted { value: _, diagnostics: _ } => false - Rejected { diagnostics: d } => xl0r_last_reason(d: d) == ^resolve_reason_unbound_symbol +// THE PROPERTY IS UNCHANGED AND THE STAGE MOVED. `r.v` on `r: Bool` must never be accepted, and it is +// not; what changed is WHERE it is refused and by what reason. v2.compiler.resolve now commits the +// field-projection SHAPE for a bound head (resolve_bound_head_projection) instead of answering +// resolve_reason_unbound_symbol, because deciding whether `v` exists needs a TYPE and resolve has none +// -- v2.std.node_query field_projection_node's own header assigns that check to the stage over this +// node. v2.compiler.infer then refuses it: `Bool`'s established type is not a record declaration, so the +// receiver declares no fields. +// +// THE OLD REASON WAS THE COLLAPSE, NOT THE PROPERTY. Answering "unbound symbol" here reported a bound +// head needing projection and a genuinely unbound name identically, which v2.compiler.resolve's own +// header records as a defect. So this claim now asserts through infer, which is what "never accepts" +// was always about: the assertion still forbids ACCEPTANCE, and it is strictly harder to satisfy than +// before, because a program that resolved and then inferred clean would now fail it. +fn xl0r_infers(o: Outcome) -> Bool { + match o { + Rejected { diagnostics: _ } => false + Accepted { value: resolved, diagnostics: _ } => + match infer(tree: resolved) { + Accepted { value: _, diagnostics: _ } => true + Rejected { diagnostics: _ } => false + } } } +test fn a_receiver_with_no_such_child_never_accepts_holds() -> Bool { + !xl0r_infers(o: xl0r_resolved_field_access_consumer()) +} + // THE CALL FORM OF THE LOCAL-RECEIVER ROW, ENROLLED ON gunbc#11683 (review 68231 asked for it by // execution, not argument). `r.v(x: p)` in a body now lowers to a Transform whose operator is the // spine `r.v` -- before that PR it lowered to Transform(r) with method and argument erased and @@ -519,11 +543,13 @@ test fn a_receiver_with_no_such_child_never_accepts_holds() -> Bool { // `lens.gate(...)`) receive under v2 resolve; no required lane compiles those modules through v2 // resolve (the native route over the corpus is a declared rung drop), so this row is the executed // evidence. Retired by the local-receiver projection, the same trigger as the value row. -test fn a_local_receiver_method_call_refuses_unbound_today_holds() -> Bool { - match xl0r_resolved_method_call_consumer() { - Accepted { value: _, diagnostics: _ } => false - Rejected { diagnostics: d } => xl0r_last_reason(d: d) == ^resolve_reason_unbound_symbol - } +// THE SAME MOVE FOR THE METHOD-CALL FORM, and the rename is the honest part: this row said "refuses +// unbound TODAY", naming a stage and a reason it never meant to pin. `r.v(x: p)` closes its spine into +// a callee at the first call suffix, so the callee `r.v` is now the projection resolve commits, and +// infer refuses it for the same reason as the bare form -- `Bool` declares no fields. What the row is +// FOR is that a method call on a local receiver is not silently admitted, and that is what it now says. +test fn a_local_receiver_method_call_never_accepts_holds() -> Bool { + !xl0r_infers(o: xl0r_resolved_method_call_consumer()) } // THE COLLISION THE ABSOLUTE ARM ADMITTED, NOW REFUSED (review 67651 on gunbc#11582). A parameter @@ -537,10 +563,35 @@ test fn a_local_receiver_method_call_refuses_unbound_today_holds() -> Bool { // answer, so the verdict is a located refusal with the section-13 reason. What this row does NOT // claim: the projection through the binder (`v2.test...` read relative to the parameter, which is // meaningless here and meaningful for `r.v`). That is the local-receiver row's trigger. -test fn a_binder_shadowing_a_root_segment_refuses_ambiguous_holds() -> Bool { +// THE GUARD DISSOLVED AND THE EVIDENCE DID NOT RETIRE (DESIGN section 4b(4)). `fn f(v2: Bool) -> Bool +// { v2.test.xl0r_provider.xl0r_provided_fn }` binds `v2` while the absolute path also answers. The +// interim rule refused that Ambiguous because v2.compiler.resolve could not project a bound head and +// therefore must not let the absolute read silently win -- its own header said so in those words. +// +// SECTION 13'S RULE IS THAT THE CHAIN WINS: the first segment resolves on the ancestor chain and the +// rest projects. The projection now exists, so the binder answers and this row asserts THAT rather +// than the refusal that stood in for it. What the old claim forbade is still forbidden -- the absolute +// read does not win over a binder the source spelled -- and is now answered correctly instead of +// refused, which is the climb the interim arm was waiting for. +// +// IT IS STILL DISCRIMINATING: an implementation that consulted the absolute candidates first would +// resolve this to the provider's declaring path, and the projection assertion below would fail. +test fn a_binder_shadowing_a_root_segment_projects_from_the_binder_holds() -> Bool { match xl0r_resolved_shadowing_consumer() { - Accepted { value: _, diagnostics: _ } => false - Rejected { diagnostics: d } => xl0r_last_reason(d: d) == ^resolve_reason_ambiguous_symbol + Rejected { diagnostics: _ } => false + Accepted { value: resolved, diagnostics: _ } => + xl0r_carries_a_field_projection(root: resolved.root) + } +} + +// EVERY PROJECTION NODE IN THE TREE, through the shared reader rather than a shape test of its own. +fn xl0r_carries_a_field_projection(root: Node) -> Bool { + match field_projection_optional(n: root) { + Present { value: _ } => true + Absent => + fold(root.children, init: false, f: fn(found, e) { + found || xl0r_carries_a_field_projection(root: e.target) + }) } } diff --git a/src/v2/test/claim/namespace_xl0/value_position_whole_read_test.dag b/src/v2/test/claim/namespace_xl0/value_position_whole_read_test.dag index 97e29563028..a029f53a7f5 100644 --- a/src/v2/test/claim/namespace_xl0/value_position_whole_read_test.dag +++ b/src/v2/test/claim/namespace_xl0/value_position_whole_read_test.dag @@ -128,7 +128,20 @@ data vpw_call_projection_source: String = "module v2.test.vpw_call_projection\n\ // a function value's minted variables must be distinct from any other function value's its types can // meet -- a nested literal reusing its parameter name, and siblings -- while the same lambda at the same // position in two declarations keeps the same type and content hash (no occurrence in the type). -data vpw_fv_nest_source: String = "module v2.test.vpw_fv_nest\n\nimport v2.std.logic { Bool }\n\nfn vpw_apply_nest(f: Bool, v: Bool) -> Bool { v }\n\nfn vpw_probe_nest(p: Bool) -> Bool {\n vpw_apply_nest(f: fn(x) {\n let g = fn(x) { x }\n g\n }, v: p)\n}\n" +// THE NESTED LITERALS BIND DIFFERENT NAMES, AND THE RENAME IS THE POINT OF THIS COMMENT. This fixture +// used to nest `fn(x) { let g = fn(x) { x } ... }` -- the same value name at both levels. That was +// INCIDENTAL to what the claim over it establishes, which is that a nested literal's fresh TYPE variables +// are not a shadow of the outer's; the shared spelling did no work for that subject. Once +// v2.compiler.resolve admitted value binders through one gate, the inner `x` hid the outer `x` and the +// fixture refused resolve_reason_binder_hides_visible_value -- so the claim could no longer reach its own +// subject, and reversing it would have deleted a property rather than recording a rule. +// +// The value-shadowing rule has its own control at its own interface +// (v2.test.claim.binder_admission.callable_binder_slice cbs_a_binder_hiding_an_enclosing_binder_refuses), +// so nothing is lost by making this fixture bind distinct names: the type-variable property stays +// exercised by two nested literals that each mint their own, and the shadowing property is asserted where +// it belongs instead of riding on an unrelated fixture. +data vpw_fv_nest_source: String = "module v2.test.vpw_fv_nest\n\nimport v2.std.logic { Bool }\n\nfn vpw_apply_nest(f: Bool, v: Bool) -> Bool { v }\n\nfn vpw_probe_nest(p: Bool) -> Bool {\n vpw_apply_nest(f: fn(x) {\n let g = fn(y) { y }\n g\n }, v: p)\n}\n" data vpw_fv_siblings_source: String = "module v2.test.vpw_fv_siblings\n\nimport v2.std.logic { Bool }\n\nfn vpw_pair(f: Bool, g: Bool, v: Bool) -> Bool { v }\n\nfn vpw_probe_siblings(p: Bool) -> Bool { vpw_pair(f: x => x, g: x => x, v: p) }\n\nfn vpw_site_one(p: Bool) -> Bool { vpw_site(f: y => y, v: p) }\n\nfn vpw_site_two(p: Bool) -> Bool { vpw_site(f: y => y, v: p) }\n\nfn vpw_site(f: Bool, v: Bool) -> Bool { v }\n\nfn vpw_order_site(f: Bool, v: Bool) -> Bool { v }\n\nfn vpw_probe_order(p: Bool) -> Bool { vpw_order_site(f: (y, x) => y, v: p) }\n" diff --git a/src/v2/test/claim/parameter_reference_test.dag b/src/v2/test/claim/parameter_reference_test.dag index 0f038db65c8..9a8881b6545 100644 --- a/src/v2/test/claim/parameter_reference_test.dag +++ b/src/v2/test/claim/parameter_reference_test.dag @@ -100,7 +100,7 @@ fn pr_assemble(src: String) -> Outcome { // resolved program, so the program is resolved once by a nullary producer the floor serves warm // (v2.workflow.floor_pure_producer_share), and each row reads only the portable values it inspects: // the parameter-reference paths, and the grounded and declared types at two paths. -data pr_program_source: String = "module p\n\nfn positive(x: Int) -> Bool { true }\n\ntype Pos = Int where positive\n\nfn f(x: Pos) -> Pos {\n x\n}\n\nfn g(x: Int) -> Int {\n x\n}\n\nfn h(x: Int) -> Int {\n let x = 1\n x\n}\n\nfn k(a: Int) -> fn(Int) -> Int {\n fn(y) { y }\n}\n" +data pr_program_source: String = "module p\n\nfn positive(x: Int) -> Bool { true }\n\ntype Pos = Int where positive\n\nfn f(x: Pos) -> Pos {\n x\n}\n\nfn g(x: Int) -> Int {\n x\n}\n\nfn h(x: Int) -> Int {\n let z = 1\n z\n}\n\nfn k(a: Int) -> fn(Int) -> Int {\n fn(y) { y }\n}\n" // Every parameter-reference path in the resolved program, in walk order; a refused program answers // Absent, which no row reads as "no parameter references". @@ -143,10 +143,25 @@ test fn pr_named_fn_parameter_is_a_parameter_reference_holds() -> Bool { }) } -// (2) A `let` NAMED LIKE A PARAMETER STILL BINDS LEXICALLY. h's body `x` is the let binder, so no -// p.h.x is minted: had the ParameterFrame answered it, p.h.x would appear. -test fn pr_let_shadowing_a_parameter_binds_lexically_holds() -> Bool { - pr_paths_hold(pred: fn(paths) { !pr_paths_contain(paths: paths, path: pr_qn(dotted: "p.h.x")) }) +// (2) A `let` NAMED LIKE A PARAMETER REFUSES, AT THE BINDER, BY REASON. A value binder may not hide an +// enclosing binder (v2.compiler.resolve admit_value_binders, the value-shadowing ruling), so the question +// this row first asked -- which frame answers a let that shadows a parameter -- has no accepted program to +// ask it of. Its own source keeps that refusal out of the shared program every other row reads; h there +// binds `z`, and its body reference is the lexical one (L1). Asserted by reason, so a program refused for +// an unrelated cause cannot satisfy it. +data pr_let_shadowing_source: String = "module p\n\nfn h(x: Int) -> Int {\n let x = 1\n x\n}\n" + +// The front end runs once, in a nullary producer the floor serves WARM (v2.workflow.floor_pure_producer_share), +// and the row reads the decided reason: one front end is more than one claim's budget. +fn pr_let_shadowing_reason() -> Symbol { + match pr_assemble(src: pr_let_shadowing_source) { + Accepted { value: _, diagnostics: _ } => ^pr_accepted + Rejected { diagnostics: d } => diagnostics_fatal_reason(d: d) + } +} + +test fn pr_let_shadowing_a_parameter_refuses_holds() -> Bool { + pr_let_shadowing_reason() == ^resolve_reason_binder_hides_visible_value } // (3) A LAMBDA'S PARAMETER STAYS ON THE LEXICAL ROUTE. Its Arrow is met below k's body, so its frame is @@ -195,14 +210,14 @@ fn pr_site_is_minted(site: NodeOccurrenceIdentity) -> Bool { } } -// (L1) h's body `x` IS A LEXICAL REFERENCE ANSWERED BY THE `let`, recorded with the let binder's minted +// (L1) h's body `z` IS A LEXICAL REFERENCE ANSWERED BY THE `let`, recorded with the let binder's minted // site and no declared type. Before the lexical carrier it was a bare atom and nothing recorded which // binder answered it, so this row reads Absent. test fn pr_let_reference_is_lexical_with_recorded_binder_holds() -> Bool { match pr_program_lexical_reads() { Absent => false Present { value: reads } => - match pr_lexical_read_of(reads: reads, label: ^x) { + match pr_lexical_read_of(reads: reads, label: ^z) { Present { value: r } => match r.binding { Present { value: b } => pr_site_is_minted(site: b.binder_site) && (b.declared == Absent) @@ -460,3 +475,26 @@ test fn pr_parameter_grounds_through_the_index_holds() -> Bool { Absent => false } } + +// (C) AN ORDINARY NAMED CALL WITH A POSITIONAL DEFICIT STILL REFUSES. The where-predicate frontier +// (v2.compiler.infer infer_where_predicate_set_edge) leaves only predicate calls unjudged; this is the +// discriminating control that the frontier did not widen to every named call. Asserted by reason, so a +// program refused for an unrelated cause cannot satisfy it. +data pr_named_call_deficit_source: String = "module p\n\nfn g(x: Int) -> Int {\n x\n}\n\nfn u() -> Int {\n g()\n}\n" + +// Same shape as row (2): one front end and one infer in a nullary WARM producer, read as the decided reason. +// A refusal at resolve answers ^pr_resolve_refused, never the infer reason, so it cannot satisfy the row. +fn pr_named_call_deficit_reason() -> Symbol { + match pr_assemble(src: pr_named_call_deficit_source) { + Rejected { diagnostics: _ } => ^pr_resolve_refused + Accepted { value: resolved, diagnostics: _ } => + match infer_and_discharge(tree: resolved) { + Accepted { value: _, diagnostics: _ } => ^pr_accepted + Rejected { diagnostics: d } => diagnostics_fatal_reason(d: d) + } + } +} + +test fn pr_ordinary_named_call_deficit_still_refuses_holds() -> Bool { + pr_named_call_deficit_reason() == ^application_positional_deficit +} diff --git a/src/v2/test/claim/parse/expression_bodied_continuation_test.dag b/src/v2/test/claim/parse/expression_bodied_continuation_test.dag new file mode 100644 index 00000000000..a346f8be606 --- /dev/null +++ b/src/v2/test/claim/parse/expression_bodied_continuation_test.dag @@ -0,0 +1,332 @@ +module v2.test.claim.parse.expression_bodied_continuation + +import v2.compiler.normalize { normalize } +import v2.compiler.normalized_tree { NormalizedTree } +import v2.compiler.parse { ParseArtifact, parse_module_prepared } +import v2.compiler.program_assembly { dag_prepared_grammar } +import v2.compiler.tokenize { tokenize } +import v2.extdeps.languages.dag { dag_lex } +import v2.std.diagnostic { + Accepted, + Diagnostics, + None, + Outcome, + Rejected, + bind_outcome, + outcome_accepted, + outcome_diagnostics +} +import v2.std.logic { Bool } +import v2.std.node { Arrow, ArrowBodyEdge, Atom, Conj, Edge, Authored, Node, Positional, Symbol, TypeNode, core_edge_label } +import v2.std.node_query { find_arrow_body_child } +import v2.std.text { String } +import std.occurrence_identity { OccurrenceSynthetic } +import v2.test.parse.expression_bodied_fn_decl_parse { + g_tokenize_parse, + braced_fn_control_source, + empty_eq_fn_source, + expression_bodied_fn_source, + expression_bodied_literal_fn_source +} + +// THE SEVEN'S CHAIN, CONTINUED PAST THE ONE CONSTRUCT THAT STOPS IT. The pinned subject +// v2.test.parse.expression_bodied_fn_decl_parse runs +// +// dag_prepared_grammar -> tokenize -> parse_module_prepared -> ParseArtifact.tree +// -> normalize -> recursive Arrow-body search +// +// and its front end stops at the MATCH ARMS in that chain, which are owned by the match-inference +// lane. This file is that same chain over the SAME sources and the SAME production functions, with +// the match arms replaced by bind_outcome -- whose continuation is an ordinary function parameter, +// not an arm binder. Nothing downstream is re-implemented: normalize, find_arrow_body_child and the +// recursive fold are the production readers the subject itself calls. +// +// SO A RED HERE IS A DOWNSTREAM DEFECT AND NOT THE MATCH BOUNDARY, which is the whole point: it +// separates "the seven are blocked by one construct" from "the seven are blocked by one construct +// AND whatever follows it", and only the second is a reason to keep waiting after the match owner +// lands. The sources are IMPORTED from the subject rather than copied, so a control here cannot +// drift from the text the seven actually parse. +// +// IT IS A CONTROL AND NOT A REWRITE. The pinned subject is untouched; when the match boundary lifts, +// the seven run unchanged and this file remains as the finer-grained account of what they cover. +fn cont_parse(text: String, file: Symbol) -> Outcome { + bind_outcome( + o: dag_prepared_grammar(), + f: fn(prepared) { + bind_outcome( + o: tokenize(text: text, file: file, rules: dag_lex()), + f: fn(token_stream) { + bind_outcome( + o: parse_module_prepared( + tokens: token_stream, + prepared: prepared, + validation_residue: outcome_diagnostics(o: dag_prepared_grammar()) + ), + f: fn(artifact) { cont_artifact_tree(artifact: artifact) } + ) + } + ) + } + ) +} + +// THE PROJECTION UNDER QUALIFICATION, on the REAL carrier. `artifact` is a ParseArtifact -- a +// production record whose `tree` field is a ParseTree (v2.std.grammar: an alias of Node) beside a +// span index and an allocator -- so this exercises the same field access the subject performs, not a +// fixture record shaped to resemble it. The receiver is a function parameter rather than a match-arm +// binder, which is exactly the isolation this file exists to make. +fn cont_artifact_tree(artifact: ParseArtifact) -> Outcome { + outcome_accepted(value: artifact.tree) +} + +// THE RESIDUE THE SUBJECT THREADS EXPLICITLY IS THREADED HERE TOO, and that is not a formality: the +// prepared grammar DOES carry residue. An earlier version of this file passed None on the assumption +// that it did not, and the claim written to check that assumption went RED -- which is the only +// reason this control now forwards the real thing rather than parsing under a residue the seven +// never use. bind_outcome cannot supply it, because it merges diagnostics into the continuation +// instead of handing them back, so the residue is read with v2.std.diagnostic outcome_diagnostics. +test fn cont_the_prepared_grammar_carries_residue_holds() -> Bool { + match outcome_diagnostics(o: dag_prepared_grammar()) { + None => false + _ => true + } +} + +fn cont_accepted(o: Outcome) -> Bool { + match o { + Accepted { value: _, diagnostics: _ } => true + Rejected { diagnostics: _ } => false + } +} + +// THE SUBJECT'S OWN RECURSIVE SEARCH, called rather than re-implemented. +fn cont_tree_has_arrow_body(root: Node) -> Bool { + match find_arrow_body_child(root: root) { + Accepted { value: _, diagnostics: _ } => true + Rejected { diagnostics: _ } => + fold(root.children, init: false, f: fn(found, e) { + found || cont_tree_has_arrow_body(root: e.target) + }) + } +} + +// NORMALIZE, THEN THE SEARCH OVER ITS ROOT. `normalized.root` is a second projection on a real +// production carrier (v2.compiler.normalized_tree NormalizedTree), read through a function parameter +// for the same reason as the artifact's. +fn cont_normalized_root(normalized: NormalizedTree) -> Outcome { + outcome_accepted(value: normalized.root) +} + +fn cont_normalized_tree(parsed: Outcome) -> Outcome { + bind_outcome( + o: parsed, + f: fn(tree) { + bind_outcome( + o: normalize(parse_tree: tree), + f: fn(normalized) { cont_normalized_root(normalized: normalized) } + ) + } + ) +} + +fn cont_normalize_has_arrow_body(parsed: Outcome) -> Bool { + match cont_normalized_tree(parsed: parsed) { + Rejected { diagnostics: _ } => false + Accepted { value: root, diagnostics: _ } => cont_tree_has_arrow_body(root: root) + } +} + +fn cont_expression_bodied_parsed() -> Outcome { + cont_parse(text: expression_bodied_fn_source, file: ^probe_expr_fn) +} + +fn cont_expression_bodied_literal_parsed() -> Outcome { + cont_parse(text: expression_bodied_literal_fn_source, file: ^probe_expr_fn_lit) +} + +fn cont_braced_parsed() -> Outcome { + cont_parse(text: braced_fn_control_source, file: ^probe_braced_fn) +} + +// THE THREE PARSE ROWS, over the subject's own sources. +test fn cont_expression_bodied_fn_decl_parses_holds() -> Bool { + cont_accepted(o: cont_expression_bodied_parsed()) +} + +test fn cont_expression_bodied_literal_fn_decl_parses_holds() -> Bool { + cont_accepted(o: cont_expression_bodied_literal_parsed()) +} + +test fn cont_braced_fn_decl_still_parses_holds() -> Bool { + cont_accepted(o: cont_braced_parsed()) +} + +// THE EMPTY-`=` ROW KEEPS ITS MEANING. The subject asserts that `fn f(a: Int) -> Int =` with no +// body is REFUSED -- a grammar that accepted it would accept a declaration with nothing to run. It +// is the one row here whose green is a refusal, so it is written as the refusal and not as the +// negation of a helper that could go green for an unrelated reason: a source that failed to TOKENIZE +// would also make `cont_accepted` false, so the arm is read directly. +test fn cont_empty_eq_fn_decl_refuses_holds() -> Bool { + match cont_parse(text: empty_eq_fn_source, file: ^probe_empty_eq) { + Rejected { diagnostics: _ } => true + Accepted { value: _, diagnostics: _ } => false + } +} + +// THE THREE NORMALIZE ROWS. THEY STAY ON THE REAL CHAIN AND THEY STAY OVER THE NEW-WITNESS MARGIN, and +// that is reported rather than engineered away. Where their cost sits was decided by measurement rather +// than by reasoning: claim_batch over this entry, run against a transient row asserting only that +// normalize ACCEPTS the braced control, puts effectively all of the expense in the parse and in +// normalize itself and effectively none in the recursive arrow-body search -- so there is no cheap half +// to remove. Both halves ARE this row's subject, which is "the declaration this parser produces still +// carries its arrow body after normalize", so there is nothing here to supply that would not sever the +// link being claimed. A hand-built parse tree handed to normalize would reach the same verdict through +// a shape this file designed rather than the one the parser emits, which is the failure DESIGN +// section 3 names as a fixture passing after the world it was built against moved. The isolating row is +// not retained, because it answered its question once and would otherwise stand as one more +// over-margin witness for a split that is now known. +// EACH SOURCE'S PARSE-THEN-NORMALIZE RUNS ONCE, in a nullary producer the floor serves WARM +// (v2.workflow.floor_pure_producer_share): a grammar-prepared parse plus normalize is more than one claim's +// new-witness budget, and each row inspects only the Bool the chain decides. +fn cont_braced_survives_normalize() -> Bool { + cont_normalize_has_arrow_body(parsed: cont_braced_parsed()) +} + +fn cont_expression_bodied_survives_normalize() -> Bool { + cont_normalize_has_arrow_body(parsed: cont_expression_bodied_parsed()) +} + +fn cont_expression_bodied_literal_survives_normalize() -> Bool { + cont_normalize_has_arrow_body(parsed: cont_expression_bodied_literal_parsed()) +} + +test fn cont_braced_fn_decl_survives_normalize_holds() -> Bool { + cont_braced_survives_normalize() +} + +test fn cont_expression_bodied_fn_decl_survives_normalize_holds() -> Bool { + cont_expression_bodied_survives_normalize() +} + +test fn cont_expression_bodied_literal_fn_decl_survives_normalize_holds() -> Bool { + cont_expression_bodied_literal_survives_normalize() +} + +// THE RECURSIVE FOLD'S NEGATIVE CONTROL, which the subject does not carry. Every row above answers +// TRUE through cont_tree_has_arrow_body, so a search that answered true for ANY tree would satisfy +// all of them and establish nothing about the search. An atom has no children and no arrow body, so +// it is the tree that must answer false -- and a fold that returned its init unconditionally, or a +// find_arrow_body_child that accepted anything, goes red here and nowhere else. +fn cont_leaf_atom() -> Node { + Node { + kind: TypeNode { connective: Atom { identity: ^cont_leaf } }, + children: [], + occurrence_id: OccurrenceSynthetic + } +} + +test fn cont_the_arrow_body_search_answers_false_for_a_leaf_holds() -> Bool { + !cont_tree_has_arrow_body(root: cont_leaf_atom()) +} + +// AND ITS POSITIVE CONTROL AT THE SAME BOUNDARY: the search must find a body that IS there, reached +// by RECURSION rather than at the root. The tree is SUPPLIED, and that is the rule rather than a +// saving: this claim's subject is one interface -- what cont_tree_has_arrow_body answers for a tree +// whose arrow body sits below the root -- and it used to obtain that tree by running +// dag_prepared_grammar, tokenize, parse_module_prepared and normalize, re-executing four stages whose +// results it never inspected (DESIGN section 3, a witness discriminates at one interface). The +// diagnostic was exact: the row's cost did not move when its assertions changed, because the +// assertions were never the expense. +// +// THE PAIRING OBLIGATION IS DISCHARGED BY cont_braced_fn_decl_survives_normalize_holds, which runs the +// real chain over the real source and finds the same body through the same search -- so the shape +// supplied here is one the real producer emits, asserted over the production route and not assumed. +// Deleting normalize from that chain makes it fail; this row would not notice, which is exactly why it +// is not the one that may stand alone. +fn cont_arrow_with_body(body: Node) -> Node { + Node { + kind: TypeNode { connective: Arrow }, + children: [ + Edge { label: core_edge_label(marker: ArrowBodyEdge), target: body } + ], + occurrence_id: OccurrenceSynthetic + } +} + +fn cont_root_over(child: Node) -> Node { + Node { + kind: TypeNode { connective: Conj }, + children: [Edge { label: Positional, target: child }], + occurrence_id: OccurrenceSynthetic + } +} + +test fn cont_the_arrow_body_search_recurses_to_find_a_body_holds() -> Bool { + let root = cont_root_over(child: cont_arrow_with_body(body: cont_leaf_atom())) + cont_tree_has_arrow_body(root: root) + && !cont_root_itself_has_arrow_body(root: root) +} + +fn cont_root_itself_has_arrow_body(root: Node) -> Bool { + match find_arrow_body_child(root: root) { + Accepted { value: _, diagnostics: _ } => true + Rejected { diagnostics: _ } => false + } +} + +// THE CONTROL PRODUCES WHAT THE SUBJECT PRODUCES, asserted rather than assumed. Everything above is +// only evidence about the seven if this chain and theirs agree, and "I used the same functions" is +// not that evidence -- the residue defect this file already caught is exactly how they can diverge +// while every row still looks green. So the two trees are compared directly, for each source, through +// the subject's own entry point. +// +// IT COMPARES THE TREE AND NOT THE VERDICT. Two chains that both answer Accepted can still have +// parsed differently; the node is what the rest of the chain consumes. +fn cont_agrees_with_subject(mine: Outcome, theirs: Outcome) -> Bool { + match mine { + Rejected { diagnostics: _ } => + match theirs { + Rejected { diagnostics: _ } => true + Accepted { value: _, diagnostics: _ } => false + } + Accepted { value: a, diagnostics: _ } => + match theirs { + Rejected { diagnostics: _ } => false + Accepted { value: b, diagnostics: _ } => a == b + } + } +} + +// +// ONE SOURCE PER ROW. The three agreements are independent cases over three different texts, so +// folding them into one row forced six parses through one enrolment budget and lost no coverage when +// split -- which is the conjunction tell DESIGN section 3 names beside the reach it is a symptom of. +test fn cont_the_continuation_parses_the_same_tree_as_the_subject_holds() -> Bool { + cont_agrees_with_subject( + mine: cont_expression_bodied_parsed(), + theirs: g_tokenize_parse(text: expression_bodied_fn_source, file: ^probe_expr_fn) + ) +} + +test fn cont_the_continuation_parses_the_literal_form_as_the_subject_does_holds() -> Bool { + cont_agrees_with_subject( + mine: cont_expression_bodied_literal_parsed(), + theirs: g_tokenize_parse(text: expression_bodied_literal_fn_source, file: ^probe_expr_fn_lit) + ) +} + +test fn cont_the_continuation_parses_the_braced_control_as_the_subject_does_holds() -> Bool { + cont_agrees_with_subject( + mine: cont_braced_parsed(), + theirs: g_tokenize_parse(text: braced_fn_control_source, file: ^probe_braced_fn) + ) +} + +// AND IT AGREES ON THE REFUSAL TOO, so the empty-`=` row is the subject's refusal and not merely a +// refusal of the same shape. +test fn cont_the_continuation_refuses_where_the_subject_refuses_holds() -> Bool { + cont_agrees_with_subject( + mine: cont_parse(text: empty_eq_fn_source, file: ^probe_empty_eq), + theirs: g_tokenize_parse(text: empty_eq_fn_source, file: ^probe_empty_eq) + ) +} diff --git a/src/v2/test/claim/projection_dispatch/receiver_disposition_test.dag b/src/v2/test/claim/projection_dispatch/receiver_disposition_test.dag new file mode 100644 index 00000000000..ac89c19e17b --- /dev/null +++ b/src/v2/test/claim/projection_dispatch/receiver_disposition_test.dag @@ -0,0 +1,66 @@ +module v2.test.claim.projection_dispatch.receiver_disposition + +import v2.std.live_tree { LiveTreeDisposition, ReadsLiveTree } +import v2.std.qualified_name { declaration_reference_node, declaration_reference_path_optional } +import v2.std.node_query { field_projection_node, field_projection_optional } +import v2.std.node { Atom, Node, Symbol, TypeNode, node_synthetic } +import std.occurrence_identity { OccurrenceSynthetic } +import v2.std.logic { Bool } +import v2.std.algebra { Cons, Empty } +import v2.std.optional { Absent, Present } + +data live_tree_disposition: LiveTreeDisposition = ReadsLiveTree + +// THE TWO REPRESENTATIONS ARE DISJOINT, AND THAT IS THE FACT THE NEXT DIAGNOSIS DEPENDS ON. A declaration +// reference is a ONE-edge marked Conj whose child is a synthetic qualified-name spine; a field projection is +// an exact TWO-edge Conj carrying the base and field roles. The readers are therefore each other's +// negative, and these rows establish it by executing both readers on both shapes rather than by reading +// their contracts. +// +// WHY THIS IS WRITTEN BEFORE ANY REPAIR. The native eight refuse with +// infer_reason_projection_receiver_declares_no_fields over an anchor that IS a declaration reference +// carrying v2 -> std -> live_tree -> LiveTreeDisposition. Two readings fit that observation and they lead +// to opposite repairs: either a declaration reference is entering the projection reader (a representation +// overlap, repaired in the readers), or the anchor is the RECEIVER'S TYPE of a genuine projection whose base +// is typed by that declaration (no overlap at all, repaired in the receiver rule). The node shape alone does +// not separate them, and an earlier causal sentence in this lane was asserted from shape alone and +// falsified by the next run. So the disjointness is measured first and the causal claim waits. +fn rd_declaration_reference() -> Node { + declaration_reference_node( + qn: Cons { head: ^v2, tail: Cons { head: ^std, tail: Cons { head: ^live_tree, tail: Cons { head: ^LiveTreeDisposition, tail: Empty } } } }, + occurrence_id: OccurrenceSynthetic + ) +} + +fn rd_atom(name: Symbol) -> Node { + node_synthetic(kind: TypeNode { connective: Atom { identity: name } }, children: Empty) +} + +fn rd_real_projection() -> Node { + field_projection_node(base: rd_atom(name: ^b), field: rd_atom(name: ^tree), source: rd_atom(name: ^src)) +} + +// (1) A DECLARATION REFERENCE IS NOT READ AS A PROJECTION. +test fn rd_a_declaration_reference_is_not_a_projection_holds() -> Bool { + (match declaration_reference_path_optional(node: rd_declaration_reference()) { + Present { value: _ } => true + Absent => false + }) + && (match field_projection_optional(n: rd_declaration_reference()) { + Absent => true + Present { value: _ } => false + }) +} + +// (2) AND A PROJECTION IS NOT READ AS A DECLARATION REFERENCE. The pair is the control: either row alone is +// satisfied by a reader that always answers Absent. +test fn rd_a_projection_is_not_a_declaration_reference_holds() -> Bool { + (match field_projection_optional(n: rd_real_projection()) { + Present { value: _ } => true + Absent => false + }) + && (match declaration_reference_path_optional(node: rd_real_projection()) { + Absent => true + Present { value: _ } => false + }) +} diff --git a/src/v2/test/claim/reference_evidence/declaration_reference_evidence_test.dag b/src/v2/test/claim/reference_evidence/declaration_reference_evidence_test.dag new file mode 100644 index 00000000000..5a61c932e04 --- /dev/null +++ b/src/v2/test/claim/reference_evidence/declaration_reference_evidence_test.dag @@ -0,0 +1,554 @@ +module v2.test.claim.reference_evidence.declaration_reference_evidence + +import v2.compiler.resolve { ResolvedTree } +import v2.std.symbol_index { symbol_index_lookup } +import v2.compiler.infer { infer, inferred_facts_grounding_derived, infer_type_equal_ignoring_provenance } +import v2.std.logic { bool_node } +import v2.compiler.inferred_tree { DerivedGrounding, GroundingNotDerived, InferredFacts, ObligatedInferredTree } +import v2.compiler.name_resolve { Admission, ResolutionSubject } +import v2.compiler.program_assembly { assemble_program_from_ingest } +import v2.compiler.source_authority { DagSourceReadWitness } +import v2.extdeps.languages.dag { dag_language_model } +import extdeps.communication.medium { Lossless, Medium } +import std.algebra { Cons, Empty } +import v2.std.algebra { fold_list } +import v2.std.cross_tree.import_model { V2Tree } +import v2.std.artifact { Artifact, SourceFile } +import v2.compiler.source_authority { SourceRootIngest } +import v2.std.diagnostic { Accepted, Outcome, Rejected } +import v2.std.logic { Bool } +import v2.std.node { Node, NodeFold, TypeNode, Atom, Arrow, ComputationNode, Transform, fold_node } +import v2.std.node_query { find_named_child, node_positional_child_targets } +import v2.std.optional { Absent, Optional, Present, optional_absent, optional_present } +import v2.std.collection { list_at_optional } +import v2.std.qualified_name { QualifiedName, declaration_reference_path_optional } + +// THE REFERENCE-EVIDENCE BOUNDARY. A reference to a corpus declaration reaches +// v2.compiler.infer's `infer_node_facts` as a declaration-reference Conj. No arm handles it: it falls +// to `inferred_facts_not_derived`, so an entry IS admitted for the node carrying GroundingNotDerived. +// Inference ACCEPTS that tree -- the refusal is eval's, whose `inferred_facts_for_eval` gate reports +// eval_rejected_grounding_not_derived only when the lookup SUCCEEDS -- so a claim asserting that +// `infer` accepted is VACUOUS here. An earlier draft of this file did exactly that and passed while +// the defect stood; the assertions below read the use's own facts instead. +// +// TWO PROPERTIES, DELIBERATELY SEPARATE. That a reference grounds AT ALL, and that it grounds to the +// contract of ITS OWN declaration. The second cannot be credited until the first is repaired -- both +// currently fail on the same missing prerequisite -- so this file specifies it now and it earns the +// name "detects declaration-identity collapse" only after the mutation that forces both uses onto one +// declaration is exercised against a repaired implementation. +// +// LOCAL PARAMETER TYPING IS NOT THIS SUBJECT. A parameter use resolves to a canonical Atom at its own +// occurrence, not to a declaring path, so `infer_parameter_scope_search` answers a different question +// and is not replaced here. The refinement rows in v2.test.claim.body_cast_node are its standing +// control and they pass on this base. +// WHY EVERY ROW HERE EXECUTES THE WHOLE FRONT END, AND WHY THAT IS NOT CONVERTED AWAY. DESIGN +// section 3's witness rule says a claim's inputs belong at the boundary it discriminates, as supplied +// values, and the rows below supply none: each assembles authored text, resolves it and infers over it. +// That was interrogated row by row against the rule rather than defended by habit, and the reason it +// stands is the one DESIGN section 4b states for the top rung -- ASK WHETHER THE CHECK'S RED IS +// AUTHORABLE BEFORE WRITING THE CHECK. +// +// THE SUBJECT OF THESE ROWS IS WHAT RESOLVE AND INFER PRODUCE FOR AUTHORED TEXT, not what one +// downstream function answers for a shape. Every row asks whether a reference or a call GROUNDS, and +// what it grounds to -- facts about the resolved-declaration index the front end mints and about the +// declared-return spelling lowering leaves behind. Against a supplied index and supplied facts each of +// those answers is a restatement of the fixture, so the row's red would not be authorable: it would +// become a decoration, which DESIGN says is worse than an absent check because it will be cited as +// coverage. The two same-leaf rows make this sharpest -- a hand-built index picks the declaration the +// author intended, which is exactly the selection under test. +// +// SO THESE ROWS ARE THE EXECUTION OF THE REAL PATH -- the second half of the same rule, which forbids +// supplying inputs anywhere if it removes the last execution of the producer. Deleting resolve's +// projection lowering, or infer's declared-field read, must make a control here fail, and does. +// +// WHAT WOULD ACTUALLY MOVE THEIR COST is not a narrower claim but a provider for the demand: their +// nullary source producers are pure functions of module-constant text, which is the shape +// v2.workflow.floor_pure_producer_share serves across claims for hundreds of peer fixtures. gunbc#12506's +// floor measured every row here past the new-witness enrolment margin, so each fixture's front end is now +// one nullary producer listed there WARM (dre_reading, below): the demand is paid once per fixture, not +// once per row, and no input is supplied, so the rows remain the real path's execution. +data dre_artifact: Artifact = Artifact { + kind: SourceFile, + id: ^declaration_reference_evidence_artifact, + file_path: "src/v2/pilot/declaration_reference_evidence_pilot.dag" +} + +fn dre_assemble(src: String) -> Outcome { + assemble_program_from_ingest( + ingest: Cons { + head: DagSourceReadWitness { + source: Medium { carried: src, fidelity: Lossless }, + artifact: dre_artifact, + compilation_unit: ^declaration_reference_evidence_cu, + source_root: V2Tree + }, + tail: Empty + }, + admission: Admission { subject: ResolutionSubject { name: Cons { head: ^p, tail: Empty } }, imports: Empty }, + lm: dag_language_model() + ) +} + +// `callee` names its parameter `only_arg`, so the expected contract is independently specified by the +// fixture text and is not read back out of the implementation under test. +fn dre_reference_source() -> Outcome { + dre_assemble(src: "module p\n\nfn callee(only_arg: Int) -> Int {\n only_arg\n}\n\nfn consumer(y: Int) -> Int {\n callee(only_arg: y)\n}\n") +} + +// The leaf is the path's last segment; QualifiedName is a FreeMonoid carrying no +// last-element reader, so the fold below keeps the final one. +// EVERY use whose decoded path ends in the wanted leaf, so the claim can require EXACTLY ONE and +// never silently select an annotation or an unrelated reference a later change introduces. +fn dre_leaf_of(path: QualifiedName) -> Optional { + fold_list(xs: path, empty: optional_absent(), cons: fn(_acc, seg) { optional_present(value: seg) }) +} + +fn dre_use_here(n: Node, wanted: Symbol) -> List { + match declaration_reference_path_optional(node: n) { + Absent => [] + Present { value: path } => + match dre_leaf_of(path: path) { + Absent => [] + Present { value: leaf } => if leaf == wanted { [n] } else { [] } + } + } +} + +fn dre_uses_of(root: Node, wanted: Symbol) -> List { + fold_node( + n: root, + algebra: NodeFold { + init: fn(n0) { dre_use_here(n: n0, wanted: wanted) }, + step: fn(acc, _e, child) { concat(acc, child) } + } + ) +} + +// The derived type's contract, read through the existing Arrow reader. A grounding tag alone is not +// the property: DerivedGrounding carrying the WRONG type must fail this control. +fn dre_grounded_domain_labels(facts: InferredFacts) -> Optional> { + match facts.grounding { + GroundingNotDerived { node: _ } => optional_absent() + DerivedGrounding { grounding: g } => + match arrow_domain_binder_labels(children: g.witness.structural.evidence.children) { + DomainBinderLabelsUnreadable => optional_absent() + DomainBinderLabels { labels: ls } => optional_present(value: ls) + } + } +} + +fn dre_single_use_domain_labels(inferred: ObligatedInferredTree, wanted: Symbol) -> Optional> { + match dre_uses_of(root: inferred.root, wanted: wanted) { + Cons { head: use_node, tail: rest } => + match rest { + Empty => + match inferred.facts.lookup(use_node) { + Absent => optional_absent() + Present { value: facts } => dre_grounded_domain_labels(facts: facts) + } + Cons { head: _, tail: _ } => optional_absent() + } + Empty => optional_absent() + } +} + +// ONE FRONT END PER FIXTURE, READ BY EVERY ROW THAT ASKS IT. Each fixture below is assembled, resolved +// and inferred ONCE, by a nullary producer the floor serves warm (v2.workflow.floor_pure_producer_share), +// and the rows read the decided, portable values: the single use's grounded domain labels, whether the +// call to the wanted callee grounds, whether infer refused, and the resolved tree's first declared +// return. Nothing is supplied: every producer runs the real assemble -> resolve -> infer path, so these +// rows stay the execution of that path, and each keeps the red it had -- the producers are the old +// readers, merged so one infer answers all of them. +type DreReading { + domain_labels: Optional> + call_grounded: Bool + infer_refuses: Bool + declared_return: Optional +} + +fn dre_reading(o: Outcome, wanted: Symbol) -> DreReading { + let declared = dre_declared_return_of(o: o) + match o { + Rejected { diagnostics: _ } => + DreReading { domain_labels: optional_absent(), call_grounded: false, infer_refuses: false, declared_return: declared } + Accepted { value: resolved, diagnostics: _ } => + match infer(tree: resolved) { + Rejected { diagnostics: _ } => + DreReading { domain_labels: optional_absent(), call_grounded: false, infer_refuses: true, declared_return: declared } + Accepted { value: inferred, diagnostics: _ } => + DreReading { + domain_labels: dre_single_use_domain_labels(inferred: inferred, wanted: wanted), + call_grounded: dre_call_is_grounded_for(inferred: inferred, wanted: wanted), + infer_refuses: false, + declared_return: declared + } + } + } +} + +fn dre_labels_are_exactly(labels: Optional>, wanted: Symbol) -> Bool { + match labels { + Absent => false + Present { value: ls } => + match ls { + Cons { head: first, tail: rest } => + match rest { + Empty => first == wanted + Cons { head: _, tail: _ } => false + } + Empty => false + } + } +} + +// RED ON THIS BASE: the single use of `callee` grounds to a callable contract whose domain binds +// `only_arg`. Fails if the reference is ungrounded, if more than one use matches, or if the derived +// type is not that Arrow. +fn dre_reference_reading() -> DreReading { + dre_reading(o: dre_reference_source(), wanted: ^callee) +} + +test fn dre_a_reference_grounds_to_its_declarations_contract_holds() -> Bool { + dre_labels_are_exactly(labels: dre_reference_reading().domain_labels, wanted: ^only_arg) +} + +// SAME LEAF, DIFFERENT DECLARING PATHS. `shared` is declared twice in this module: as a top-level +// function (p.shared, whose domain binds `alpha`) and as a field of Wrap (p.Wrap.shared). The use in +// `consumer` denotes p.shared. A leaf-keyed lookup can answer either; the declaring path answers only +// one. This control is SPECIFIED but not yet QUALIFIED as an identity-collapse detector: that claim +// requires the forced-collapse mutation to turn it red, which is exercised separately. +fn dre_same_leaf_source() -> Outcome { + dre_assemble(src: "module p\n\nfn shared(alpha: Int) -> Int {\n alpha\n}\n\ntype Wrap {\n shared: Int\n}\n\nfn consumer(y: Int) -> Int {\n shared(alpha: y)\n}\n") +} + +fn dre_same_leaf_reading() -> DreReading { + dre_reading(o: dre_same_leaf_source(), wanted: ^shared) +} + +test fn dre_a_same_leaf_reference_gets_its_own_declarations_contract_holds() -> Bool { + dre_labels_are_exactly(labels: dre_same_leaf_reading().domain_labels, wanted: ^alpha) +} + +// THE TWO-MODULE SAME-LEAF DISCRIMINATOR IS NOT IN THIS FILE, AND THAT IS A GAP, NOT AN OMISSION. +// Five attempts did not produce one that discriminates. A single-module fixture cannot: a reference +// reaching this arm denotes a module-level callable whose declaring path IS [module, leaf], so path +// and leaf coincide and a leaf-keyed lookup is accidentally right. A two-module fixture reaches the +// right shape -- two modules each declaring `shared(alpha: ...)` with different types, the consumer +// importing one -- but the assertion needs the parameter's TYPE read out of the derived Arrow's +// domain, and neither a walk-order atom search nor find_named_child on the domain produced the +// declared type: the control stayed green under a mutation that forced the wrong declaration, then +// went red on correct code once the reader changed. Both arms were wrong, so it distinguished +// nothing. +// +// WHAT IS THEREFORE NOT CLAIMED: that this arm resists declaration-identity collapse. The path is +// what it looks up and symbol_index_lookup is the guarded read, but no executed control here +// demonstrates that a leaf-keyed answer would be caught. Qualifying it needs a reliable reader for a +// parameter's declared type inside a derived Arrow domain, which is the missing piece and is worth +// finding before the claim is made. + +// THE APPLICATION CONSUMER. A grounded reference is not a grounded application: at the base, +// infer_application_callee_arrow read the callee EXPRESSION's own kind, and a resolved declaration +// reference stays a Conj however well typed, so every consumer -- formals, type parameters, argument +// inhabitance, result typing -- fell through. dre_call_is_grounded_for asserts the CALL's facts, not the +// reference's. +// +// THE CALL GROUNDS. Two changes were needed and only the first is obvious: the application path had to +// SEE the callee's callable evidence (infer_application_callee_arrow_with_facts), and the grounding +// check had to be asked of the CALLEE USE rather than of the arrow. That second lookup was keyed by +// `arrow`, which is correct only while an arrow can only be the callee node itself; once the arrow may +// be a DECLARATION's Arrow reached through the use's facts, it is a node of the declaring module with +// no entry in this tree, so the lookup answered Absent and the application dropped to the frontier +// however well the callee was typed. +test fn dre_a_named_call_is_grounded_holds() -> Bool { + dre_reference_reading().call_grounded +} + +// EXECUTION, DELIBERATELY BOOL-RETURNING. The point is that a named call EXECUTES and returns its +// callee's value -- not that equality types. An Int-returning call compared with `==` would couple this +// first execution proof to the equality operation, which has its own typing rule and its own unproven +// standing; a Bool-returning call is its own assertion. +fn dre_execution_source() -> Outcome { + dre_assemble(src: "module p\n\nfn truth(only_arg: Int) -> Bool {\n true\n}\n\nfn consumer(y: Int) -> Bool {\n truth(only_arg: y)\n}\n") +} + +// A BOOL-RETURNING CALL DOES NOT GROUND, AND THAT IS A SEPARATE BOUNDARY FROM THIS REPAIR. Measured +// three ways on this base: an Int-returning call grounds; a Bool-returning call with a literal body +// does not; a Bool-returning call whose body is its parameter does not either. So the variable is the +// RETURN TYPE, not the body. The reference itself grounds in every case, so this is downstream of the +// reference repair, in the application's return derivation -- +// infer_arrow_declared_return_type -> dag_binding_denotation. Enrolled executed rather than deleted, +// and NOT diagnosed further here: which binding symbol a Bool return actually carries is the next +// question, and answering it is a separate change from this one. +fn dre_execution_reading() -> DreReading { + dre_reading(o: dre_execution_source(), wanted: ^truth) +} + +test fn dre_a_named_call_to_a_bool_fn_is_grounded_holds() -> Bool { + dre_execution_reading().call_grounded +} + +fn dre_call_is_grounded_for(inferred: ObligatedInferredTree, wanted: Symbol) -> Bool { + match dre_first_application_for(n: inferred.root, wanted: wanted) { + Absent => false + Present { value: call } => + match inferred.facts.lookup(call) { + Absent => false + Present { value: facts } => inferred_facts_grounding_derived(facts: facts) + } + } +} + +fn dre_first_application_for(n: Node, wanted: Symbol) -> Optional { + fold_node( + n: n, + algebra: NodeFold { + init: fn(n0) { + match n0.kind { + ComputationNode { behavior: Transform } => + match dre_uses_of(root: n0, wanted: wanted) { + Cons { head: _, tail: _ } => optional_present(value: n0) + Empty => optional_absent() + } + _ => optional_absent() + } + }, + step: fn(acc, _e, child) { + match acc { Present { value: _ } => acc Absent => child } + } + } + ) +} + + +// Bool param AND Bool return, body is the parameter -- the same shape as the working Int case, so the +// only variable against dre_execution_source is the literal body. +fn dre_bool_param_source() -> Outcome { + dre_assemble(src: "module p\n\nfn truth(only_arg: Bool) -> Bool {\n only_arg\n}\n\nfn consumer(y: Bool) -> Bool {\n truth(only_arg: y)\n}\n") +} + + +fn dre_return_atom_of(o: Outcome, wanted: Symbol) -> Optional { + match o { + Rejected { diagnostics: _ } => optional_absent() + Accepted { value: resolved, diagnostics: _ } => + match infer(tree: resolved) { + Rejected { diagnostics: _ } => optional_absent() + Accepted { value: inferred, diagnostics: _ } => + match dre_uses_of(root: inferred.root, wanted: wanted) { + Empty => optional_absent() + Cons { head: use_node, tail: _ } => + match inferred.facts.lookup(use_node) { + Absent => optional_absent() + Present { value: facts } => + match facts.grounding { + GroundingNotDerived { node: _ } => optional_absent() + DerivedGrounding { grounding: g } => + match list_at_optional(xs: node_positional_child_targets(node: g.witness.structural.evidence), index: 1) { + Absent => optional_absent() + Present { value: ret } => + match ret.kind { + TypeNode { connective: Atom { identity: id } } => optional_present(value: id) + _ => optional_absent() + } + } + } + } + } + } + } +} + + + + + + + +// THE MISMATCH NEGATIVES. Recognising a Bool return must ACTIVATE the body-versus-declared-return +// check, not merely admit more nodes: infer_arrow_declared_return_type is shared by that check and by +// application result typing, and while it answered Absent for Bool the check SKIPPED its comparison. +// So a Bool-declared function with an Int body, and the converse, must still refuse. +fn dre_bool_declared_int_body() -> Outcome { + dre_assemble(src: "module p\n\nfn wrong(only_arg: Int) -> Bool {\n only_arg\n}\n") +} + +fn dre_int_declared_bool_body() -> Outcome { + dre_assemble(src: "module p\n\nfn wrong(only_arg: Bool) -> Int {\n only_arg\n}\n") +} + +fn dre_bool_declared_int_body_reading() -> DreReading { + dre_reading(o: dre_bool_declared_int_body(), wanted: ^wrong) +} + +fn dre_int_declared_bool_body_reading() -> DreReading { + dre_reading(o: dre_int_declared_bool_body(), wanted: ^wrong) +} + +// THE CHECK IS NOW REACHABLE, SO THESE REFUSE. They were red until the return-type ATOM grounded: +// infer_arrow_body_inhabits_declared_return runs only when infer_product_child_evidence_edges answers +// Present, which requires EVERY Arrow child to carry a resolved type, and the Bool return atom carried +// none -- so the Arrow dropped to the frontier and the comparison never ran. A frontier is not a +// refusal, which is why the mismatch was ACCEPTED rather than reported. +// +// These are the controls that distinguish a return-type recognition which activates the check from one +// that merely admits more nodes. Recognition alone would have left them red. +test fn dre_a_bool_declared_int_body_still_refuses_holds() -> Bool { + dre_bool_declared_int_body_reading().infer_refuses +} + +test fn dre_an_int_declared_bool_body_still_refuses_holds() -> Bool { + dre_int_declared_bool_body_reading().infer_refuses +} + +// A SECOND SOURCE FILE, so a reference can cross a module boundary. +fn dre_file(src: String, id: Symbol) -> DagSourceReadWitness { + DagSourceReadWitness { + source: Medium { carried: src, fidelity: Lossless }, + artifact: Artifact { kind: SourceFile, id: id, file_path: "src/v2/pilot/declaration_reference_evidence_pilot.dag" }, + compilation_unit: id, + source_root: V2Tree + } +} + +// UNRESOLVED SIGNATURE: readable shape, unavailable type evidence. The parameter's declared type names +// nothing, so the signature's structure reads while its meaning does not. A permissive fallback would +// ground this; it must stay underived. +fn dre_unresolved_signature_source() -> Outcome { + dre_assemble(src: "module p\n\nfn opaque(only_arg: Nonexistent) -> Int {\n 1\n}\n\nfn consumer(y: Int) -> Int {\n opaque(only_arg: y)\n}\n") +} + +fn dre_unresolved_signature_reading() -> DreReading { + dre_reading(o: dre_unresolved_signature_source(), wanted: ^opaque) +} + +test fn dre_an_unresolved_signature_does_not_ground_holds() -> Bool { + match dre_unresolved_signature_reading().domain_labels { + Absent => true + Present { value: _ } => false + } +} + +// INVALID ARGUMENT: a Bool passed where the declared parameter is Int. The call must not ground on a +// contract its argument does not satisfy. +fn dre_invalid_argument_source() -> Outcome { + dre_assemble(src: "module p\n\nfn wants_int(only_arg: Int) -> Int {\n only_arg\n}\n\nfn consumer(y: Bool) -> Int {\n wants_int(only_arg: y)\n}\n") +} + +fn dre_invalid_argument_reading() -> DreReading { + dre_reading(o: dre_invalid_argument_source(), wanted: ^wants_int) +} + +test fn dre_an_invalid_argument_call_does_not_ground_holds() -> Bool { + dre_invalid_argument_reading().call_grounded == false +} + +// IMPORTED REFERENCE, AND THE QUALIFIED IDENTITY DISCRIMINATOR. The same mechanism across a module +// boundary: local and imported references reach the declaration by the same route, since the declaring +// path is what the lookup uses either way. +// +// QUALIFIED BY MUTATION, unlike the earlier single-module attempt that could not be. Repointing this +// reference's lookup at a DIFFERENT EXISTING declaration -- m.app rather than m.lib.helper, so the +// lookup still SUCCEEDS -- turns this row red while the same-module call stays green. So it detects +// wrong-declaration selection rather than missing evidence, which is the property a leaf-keyed lookup +// would violate and an absent-path mutation could never establish. +fn dre_imported_reference_source() -> Outcome { + assemble_program_from_ingest( + ingest: Cons { + head: dre_file(src: "module m.lib\n\nfn helper(only_arg: Int) -> Int {\n only_arg\n}\n", id: ^dre_imported_lib_cu), + tail: Cons { + head: dre_file(src: "module m.app\n\nimport m.lib { helper }\n\nfn consumer(y: Int) -> Int {\n helper(only_arg: y)\n}\n", id: ^dre_imported_app_cu), + tail: Empty + } + }, + admission: Admission { + subject: ResolutionSubject { name: Cons { head: ^m, tail: Cons { head: ^app, tail: Empty } } }, + imports: Empty + }, + lm: dag_language_model() + ) +} + +// A CROSS-MODULE REFERENCE DOES NOT GROUND, AND THAT IS THE SCOPE OF THE CARRIER, NOT A FAIL-OPEN. +// The declaration a reference grounds to is read from v2.compiler.resolve ResolvedTree +// resolved_declarations -- DECLARATIONS WITH RESOLVED BODIES -- because a declaration's return atom as +// AUTHORED is the source spelling `Int`, which v2.extdeps.languages.dag dag_binding_denotation does not +// denote (it is a binding-to-type table, and the authored spelling is not a binding). Reading the +// pre-resolve symbol_index instead is what made every named call's result typing fall to the frontier. +// +// resolve ONCE walked a single module root and minted resolved_declarations over it, so that index carried +// the SUBJECT module's declarations and no other. m.lib.helper was absent from it, the lookup answered +// Absent, and the reference stayed underived. The two tempting repairs were refused then and stay refused: +// falling back to the authored index is the absorbing fallback DESIGN section 5 forbids, and re-deriving the +// return from the language's kernel spelling table inside infer is a second resolution authority (section 3). +// +// THE TRIGGER THIS ROW DECLARED HAS LANDED, which is why the row now reads in the opposite direction. It +// named "a resolved-declaration index over every resolved module root ... minted by resolve beside the +// per-module carrier", and that is what v2.compiler.resolve resolved_tree_of now receives: the closure's +// resolved roots, folded through the existing single-root door, assembled by v2.compiler.name_resolve +// closure_declarations_demand. The fact stayed resolve's to own, as this row said it must. +// +// DESIGN section 4b(4) IS WHY THIS DID NOT RETIRE. An expecting-red probe that greens when its wall lands +// flips to a permanent regression control; it does not disappear, because the climb's evidence is what keeps +// the higher rung real. So the row keeps its subject and its fixture and inverts its assertion, and its name +// now states what holds rather than what is missing. The single-module rows above remain the positive +// controls that the mechanism works at all, so this row cannot be satisfied by grounding being broken +// everywhere -- and it goes red again the moment the closure index stops reaching an imported declaration. +fn dre_imported_reference_reading() -> DreReading { + dre_reading(o: dre_imported_reference_source(), wanted: ^helper) +} + +test fn dre_an_imported_reference_grounds_through_the_closure_index_holds() -> Bool { + dre_imported_reference_reading().call_grounded +} + +// THE ASYMMETRY THE TWO REFUSAL ROWS ABOVE DEPEND ON, MEASURED RATHER THAN ASSERTED. A declared `Int` +// return reaches infer as the canonical BINDING ^dag_binding_type_int, while a declared `Bool` return +// reaches it as the DENOTED value type (v2.std.logic bool_node) -- so the binding->value-type join +// v2.extdeps.languages.dag dag_binding_denotation answers for one and not the other. That is why +// v2.compiler.infer asks infer_established_value_type_optional beside the join at both declared-position +// readers, and why declared-Int mismatches refused for a while where the identical declared-Bool +// mismatch was accepted at the frontier. +// +// THESE TWO ROWS ARE THE STRUCTURAL CONTROL FOR THAT REPAIR, not a restatement of it: if lowering ever +// canonicalizes Bool to a binding, or stops canonicalizing Int, one of them goes red and the reader that +// depends on the current shape is the thing to re-read. A refusal row alone could not say which. +fn dre_arrow_of(o: Outcome) -> Optional { + match o { + Rejected { diagnostics: _ } => optional_absent() + Accepted { value: r, diagnostics: _ } => + fold_node( + n: r.root, + algebra: NodeFold { + init: fn(n0) { + match n0.kind { TypeNode { connective: Arrow } => optional_present(value: n0) _ => optional_absent() } + }, + step: fn(acc, _e, child) { match acc { Present { value: _ } => acc Absent => child } } + } + ) + } +} + +fn dre_declared_return_of(o: Outcome) -> Optional { + match dre_arrow_of(o: o) { + Absent => optional_absent() + Present { value: a } => list_at_optional(xs: node_positional_child_targets(node: a), index: 1) + } +} + +test fn dre_a_declared_bool_return_is_already_the_bool_value_type_holds() -> Bool { + match dre_bool_declared_int_body_reading().declared_return { + Absent => false + Present { value: ret } => infer_type_equal_ignoring_provenance(a: ret, b: bool_node()) + } +} + +test fn dre_a_declared_int_return_is_the_int_binding_holds() -> Bool { + match dre_int_declared_bool_body_reading().declared_return { + Absent => false + Present { value: ret } => + match ret.kind { + TypeNode { connective: Atom { identity: s } } => s == ^dag_binding_type_int + _ => false + } + } +} diff --git a/src/v2/test/lens_common/infer_fixture.dag b/src/v2/test/lens_common/infer_fixture.dag index fdc36ff11e4..e329a948367 100644 --- a/src/v2/test/lens_common/infer_fixture.dag +++ b/src/v2/test/lens_common/infer_fixture.dag @@ -1,4 +1,5 @@ module v2.test.lens_common.infer_fixture +import v2.std.optional { optional_absent } import std.occurrence_identity { OccurrenceSynthetic } import v2.compiler.resolve { ResolvedTree } @@ -57,7 +58,8 @@ fn claim_inferred_facts_witness( ) -> InferredFacts { InferredFacts { grounding: DerivedGrounding { grounding: grounding }, - descent: descent + descent: descent, + denotation: optional_absent() } } diff --git a/src/v2/workflow/floor_cost_debt.dag b/src/v2/workflow/floor_cost_debt.dag index 9da8f7912ff..9a55818aa3b 100644 --- a/src/v2/workflow/floor_cost_debt.dag +++ b/src/v2/workflow/floor_cost_debt.dag @@ -721,7 +721,7 @@ fn floor_cost_debt_proven_chunk_06() -> List { } fn floor_cost_debt_proven_chunk_07() -> List { - Cons { head: "v2.test.claim.dag_acceptance.acceptance_translate_row_is_determinate", tail: Cons { head: "v2.test.claim.enforcement.cost_coverage_witness.cost_coverage_reject_is_typed_not_silent", tail: Cons { head: "v2.test.claim.enforcement.cost_coverage_witness.cost_coverage_smoke_recomputed_by_execution_holds", tail: Cons { head: "v2.test.claim.enforcement.cost_coverage_witness.cost_coverage_smoke_totality_holds", tail: Cons { head: "v2.test.claim.enforcement.grammar_coverage_witness.grammar_coverage_reject_is_typed_not_silent", tail: Cons { head: "v2.test.claim.enforcement.grammar_coverage_witness.grammar_coverage_smoke_recomputed_by_execution_holds", tail: Cons { head: "v2.test.claim.enforcement.grammar_coverage_witness.grammar_coverage_smoke_totality_holds", tail: Cons { head: "v2.test.claim.fold_lowering.algebra_carrying_fold_is_step_form_unresolved", tail: Cons { head: "v2.test.claim.fold_lowering.bodyless_fold_call_refuses_no_loop_fabricated", tail: Cons { head: "v2.test.claim.fold_lowering.bodyless_fold_is_step_form_unresolved", tail: Cons { head: "v2.test.claim.fold_lowering.duplicate_carrier_edge_is_malformed", tail: Cons { head: "v2.test.claim.fold_lowering.fold_call_lowers_to_terminating_loop", tail: Cons { head: "v2.test.claim.fold_lowering.fold_callee_name_survives_parse", tail: Cons { head: "v2.test.claim.fold_lowering.fold_collection_name_survives_parse", tail: Cons { head: "v2.test.claim.fold_lowering.fold_probe_ingest_is_accepted", tail: Cons { head: "v2.test.claim.fold_lowering.lowered_loop_binds_carrier_binder", tail: Cons { head: "v2.test.claim.fold_lowering.named_step_fold_is_step_form_unresolved", tail: Cons { head: "v2.test.claim.fold_lowering.non_fold_call_is_not_a_fold_disposition", tail: Cons { head: "v2.test.claim.fold_lowering.non_fold_call_refuses", tail: Cons { head: "v2.test.claim.fold_lowering.paramless_step_fold_is_step_shape_invalid", tail: Empty {} } } } } } } } } } } } } } } } } } } } } + Cons { head: "v2.test.claim.dag_acceptance.acceptance_translate_row_is_determinate", tail: Cons { head: "v2.test.claim.enforcement.cost_coverage_witness.cost_coverage_reject_is_typed_not_silent", tail: Cons { head: "v2.test.claim.enforcement.cost_coverage_witness.cost_coverage_smoke_recomputed_by_execution_holds", tail: Cons { head: "v2.test.claim.enforcement.cost_coverage_witness.cost_coverage_smoke_totality_holds", tail: Cons { head: "v2.test.claim.enforcement.grammar_coverage_witness.grammar_coverage_reject_is_typed_not_silent", tail: Cons { head: "v2.test.claim.enforcement.grammar_coverage_witness.grammar_coverage_smoke_recomputed_by_execution_holds", tail: Cons { head: "v2.test.claim.enforcement.grammar_coverage_witness.grammar_coverage_smoke_totality_holds", tail: Cons { head: "v2.test.claim.fold_lowering.algebra_carrying_fold_is_step_form_unresolved", tail: Cons { head: "v2.test.claim.fold_lowering.bodyless_fold_call_refuses_no_loop_fabricated", tail: Cons { head: "v2.test.claim.fold_lowering.bodyless_fold_is_step_form_unresolved", tail: Cons { head: "v2.test.claim.fold_lowering.duplicate_carrier_edge_is_malformed", tail: Cons { head: "v2.test.claim.fold_lowering.fold_call_lowers_to_terminating_loop", tail: Cons { head: "v2.test.claim.fold_lowering.fold_callee_name_survives_parse", tail: Cons { head: "v2.test.claim.fold_lowering.fold_collection_name_survives_parse", tail: Cons { head: "v2.test.claim.fold_lowering.fold_probe_ingest_is_accepted", tail: Cons { head: "v2.test.claim.fold_lowering.lowered_loop_carrier_is_a_generated_slot_not_the_authored_binder", tail: Cons { head: "v2.test.claim.fold_lowering.named_step_fold_is_step_form_unresolved", tail: Cons { head: "v2.test.claim.fold_lowering.non_fold_call_is_not_a_fold_disposition", tail: Cons { head: "v2.test.claim.fold_lowering.non_fold_call_refuses", tail: Cons { head: "v2.test.claim.fold_lowering.paramless_step_fold_is_step_shape_invalid", tail: Empty {} } } } } } } } } } } } } } } } } } } } } } fn floor_cost_debt_proven_chunk_08() -> List { diff --git a/src/v2/workflow/floor_pure_producer_share.dag b/src/v2/workflow/floor_pure_producer_share.dag index 6c9461b1448..7b89b65b6a7 100644 --- a/src/v2/workflow/floor_pure_producer_share.dag +++ b/src/v2/workflow/floor_pure_producer_share.dag @@ -759,7 +759,27 @@ import v2.std.collection { List } // inspected only a list of paths or four types. The rows now ask one program, so the share point is // the two nullary producers over it. The values are an Optional list of QualifiedNames and a record of // four Optional -- portable, no closure, no resolution context -- and both are WARM for the -// ceiling arithmetic above: one front end is more than one claim's budget. +// ceiling arithmetic above: one front end is more than one claim's budget. gunbc#12506 added two rows on +// their own sources (a let hiding a parameter refuses; an ordinary named call with a positional deficit +// refuses), each read through a nullary producer answering the decided reason Symbol, WARM for the same +// arithmetic. The same change's v2.test.claim.parse.expression_bodied_continuation reads each source's +// parse-then-normalize verdict through one nullary Bool producer per source, WARM because a prepared parse plus +// normalize is past one claim's budget. +// THE REFERENCE-EVIDENCE ROWS READ ONE FRONT END PER FIXTURE, NOT ONE PER ROW. gunbc#12506's floor +// refused v2.test.claim.reference_evidence.declaration_reference_evidence's eleven rows past the new-witness +// enrolment margin: each assembled, resolved and inferred its fixture itself, and several fixtures were +// asked by two rows. Each of its eight fixtures is now one nullary producer answering a DreReading -- the +// single use's grounded domain labels, whether the call grounds, whether infer refused, the declared +// return -- portable Optional lists of Symbols, Bools and an Optional. WARM: one front end plus infer +// is past one claim's budget. +// THE NAMED-CALL EVAL ROWS READ ONE FRONT END AND ONE EVAL PER FIXTURE. The same floor refused ten rows of +// v2.test.claim.callexec.declaration_reference_eval, each of which ran assemble -> resolve -> infer -> eval +// inline. Each of its eight fixtures is now one nullary producer answering a CrefReading (call found, +// declaration carried, executed, the result as an Optional and as Optional primitive bytes). WARM for +// the same arithmetic. +// THE SYNTHETIC-KEY ROWS READ ONE INFER PER FIXTURE. v2.test.claim.callexec.synthetic_facts_key_collision's +// four rows each re-inferred one of two fixtures (two rows re-inferred theirs twice). Each fixture is one +// nullary producer answering SfkCounts, three portable scalars. WARM for the same arithmetic. // THE NEWLINE-REFUSAL MUTATION'S TWO GRAMMAR FACTS ARE SUPPLIED, NOT RE-DERIVED PER CLAIM. // v2.test.parse.newline_dual_role_operator_mutation discriminates the newline-`-` refusal by parsing // three one-line sources under the live grammar and under a mutant with every RefuseOnMatch arm @@ -771,6 +791,29 @@ import v2.std.collection { List } data floor_cross_claim_pure_producers_warm: List = [ "v2.test.claim.parameter_reference.pr_program_parameter_paths", "v2.test.claim.parameter_reference.pr_program_grounding", + "v2.test.claim.parameter_reference.pr_let_shadowing_reason", + "v2.test.claim.parameter_reference.pr_named_call_deficit_reason", + "v2.test.claim.parse.expression_bodied_continuation.cont_braced_survives_normalize", + "v2.test.claim.parse.expression_bodied_continuation.cont_expression_bodied_survives_normalize", + "v2.test.claim.parse.expression_bodied_continuation.cont_expression_bodied_literal_survives_normalize", + "v2.test.claim.reference_evidence.declaration_reference_evidence.dre_reference_reading", + "v2.test.claim.reference_evidence.declaration_reference_evidence.dre_same_leaf_reading", + "v2.test.claim.reference_evidence.declaration_reference_evidence.dre_execution_reading", + "v2.test.claim.reference_evidence.declaration_reference_evidence.dre_bool_declared_int_body_reading", + "v2.test.claim.reference_evidence.declaration_reference_evidence.dre_int_declared_bool_body_reading", + "v2.test.claim.reference_evidence.declaration_reference_evidence.dre_unresolved_signature_reading", + "v2.test.claim.reference_evidence.declaration_reference_evidence.dre_invalid_argument_reading", + "v2.test.claim.reference_evidence.declaration_reference_evidence.dre_imported_reference_reading", + "v2.test.claim.callexec.declaration_reference_eval.cref_source_reading", + "v2.test.claim.callexec.declaration_reference_eval.cref_zero_arg_reading", + "v2.test.claim.callexec.declaration_reference_eval.cref_one_arg_constant_body_reading", + "v2.test.claim.callexec.declaration_reference_eval.cref_bool_pair_reading", + "v2.test.claim.callexec.declaration_reference_eval.cref_bool_false_reading", + "v2.test.claim.callexec.declaration_reference_eval.cref_unresolved_callee_reading", + "v2.test.claim.callexec.declaration_reference_eval.cref_identity_three_reading", + "v2.test.claim.callexec.declaration_reference_eval.cref_identity_eight_reading", + "v2.test.claim.callexec.synthetic_facts_key_collision.sfk_cref_source_counts", + "v2.test.claim.callexec.synthetic_facts_key_collision.sfk_bool_pair_counts", "v2.test.claim.text_string_importer_census.tsic_real_route_outcome", "v2.test.claim.text_string_importer_census.tsic_window_coverage_outcome", "v2.test.claim.text_string_importer_census.tsil_literal_rows_outcome",