From 2510fc12a4a840d90b0b09f8b04681080d0c2fe4 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Mon, 21 Sep 2026 20:26:28 +0000 Subject: [PATCH 01/17] The candidate rule is the chain, so an import becomes a binding on it and the global spelling search is deleted The native route refused the fixed workload at prepare on ONE reference -- `SubstrateInputsOnly`, the bare variant in the `data live_tree_disposition` row -- with resolve_ambiguous_on_global_bare, naming no binder. Reading the slice from the top: v2's resolver ALREADY walks the ancestor chain and already refuses when two positions bind (docs/plans/namespace-resolution-design.md section 13's unique-on-chain, stricter than [basic.lookup.unqual]'s nearest-wins as PR #11924's departure note says). The chain was never the defect. What was missing is the mechanism section 13 says must land WITH OR BEFORE the strict flip: import->alias transmutation. Under NamespaceOnlyY imports were admitted nowhere, so every cross-module reference was off its own chain and fell through to symbol_index_global_unique_lookup -- "imports-deleted-first", which that section names as the thing never to do, with the global-bare tier standing in for it. Deleting the tier alone would have turned the workload's references unbound, not resolved. So the three land together, as one authority transition: 1. An import is transmuted into AliasBindingRow rows -- one per NAMED ITEM, never the whole module, because wholesale admission is the using-directive the cited model carries as an excluded form. The rows are captured at NORMALIZE, beside test_marker_capture and for the same reason: the module graft dissolves import decls (UnitDissolved), so that is the last moment they exist. They ride on NormalizedTree, the carrier that already carries one graft-erased fact. 2. The fill is two passes over the roots -- every root's declarations, then every root's binding rows. One pass made a binding row that named a not-yet-walked root bind nothing, so whether a cross-module reference resolved depended on FILE ORDER. That is removed by construction, not by sorting. 3. The global bare search is deleted as a RESOLUTION mechanism, with AmbiguousOnGlobalBare -- the one ambiguity class that could name no binder. The index's global_bare map survives as exactly what section 13 leaves it: the migration oracle. Identity at a binding position is the DECLARING PATH, not the node. The cheaper test -- treat a second writer as the same binding when its node is equal -- was measured merging two genuinely distinct binders, because `type NcrTwin = Bool` in two modules builds the same node: the ambiguity control passed as RESOLVED until this was fixed. Evidence, five arms on the real route (supplied source bytes, then production tokenize, parse, normalize and resolve via native_test_context_from_ingest): - the workload's own refusal at fixture scale -- a bare variant whose spelling a second module also uses -- resolves through its import; - a same-named pair in sibling scopes resolves by containment; - a genuine ambiguity (one name bound at one position by two imports) refuses naming BOTH binders, count asserted; - a name bound on no chain that the corpus spells twice is UNBOUND, not ambiguous; - permuting the ingest changes no verdict, compared at cause and binder-set grain. Mutation control RUN, not described: reinstating the global bare search reds an_unbound_name_the_corpus_spells_twice_stays_unbound and the re-homed arm in native_refusal_detail, and nothing else. Disposition of what is replaced: native_refusal_detail's global_bare_collision_row_names_its_lookup_class_without_fabricating_candidates asserted the old answer for a fixture whose question has changed. It is not retired -- it becomes a_bare_name_bound_on_no_chain_is_unbound_not_ambiguous and stays enrolled as the discriminator for the deletion (DESIGN section 4b(4)). Co-Authored-By: Claude Opus 5 (1M context) --- src/v2/compiler/03_name_resolve.dag | 4 +- src/v2/compiler/03_normalize.dag | 57 ++- src/v2/compiler/03_resolve.dag | 56 +-- .../declaring_identity_spelling_census.dag | 4 +- src/v2/compiler/normalized_tree.dag | 56 ++- .../compiler/repair_input_origin_roster.dag | 4 +- src/v2/compiler/symbol_index_fill.dag | 182 ++++++- src/v2/extdeps/languages/dag.dag | 89 ++++ src/v2/std/namespace_alias.dag | 15 +- src/v2/std/symbol_index.dag | 144 +++++- .../production_ingest_test.dag | 4 +- .../native_refusal_detail_test.dag | 20 +- .../provenance/cross_file_provenance_test.dag | 2 +- .../loaded_carrier_receipts_test.dag | 4 +- .../resolve/namespace_candidate_rule_test.dag | 476 ++++++++++++++++++ .../claim/symbol_index/containment_test.dag | 2 +- 16 files changed, 1022 insertions(+), 97 deletions(-) create mode 100644 src/v2/test/claim/resolve/namespace_candidate_rule_test.dag diff --git a/src/v2/compiler/03_name_resolve.dag b/src/v2/compiler/03_name_resolve.dag index e6f83993eb3..56980cb0e3d 100644 --- a/src/v2/compiler/03_name_resolve.dag +++ b/src/v2/compiler/03_name_resolve.dag @@ -3,7 +3,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.normalized_tree { NormalizedTree, normalized_tree_roots_to_nodes } +import v2.compiler.normalized_tree { NormalizedTree, normalized_tree_roots_to_binding_sources, normalized_tree_roots_to_nodes } import v2.std.declaration_marker { TestCodeIndex, test_code_index_add_module, test_code_index_empty } import v2.std.symbol_index { SymbolIndex, empty_symbol_index } import v2.compiler.resolve { @@ -130,7 +130,7 @@ fn resolution_context( roots: validated_roots, symbol_index: symbol_index_fill_module_roots( index: empty_symbol_index(), - roots: normalized_tree_roots_to_nodes(roots: validated_roots.roots) + roots: normalized_tree_roots_to_binding_sources(roots: validated_roots.roots) ) }, diagnostics: d diff --git a/src/v2/compiler/03_normalize.dag b/src/v2/compiler/03_normalize.dag index 16e92d6be19..b00ecb6b8dd 100644 --- a/src/v2/compiler/03_normalize.dag +++ b/src/v2/compiler/03_normalize.dag @@ -1,6 +1,8 @@ module v2.compiler.normalize -import v2.std.algebra { list_snoc_item } +import v2.std.algebra { fold_list, list_snoc_item } +import std.algebra { Empty, FreeMonoid } +import v2.std.namespace_alias { AliasBindingRow } import v2.std.collection { Absent, Present, optional_absent, optional_present } import v2.std.grammar { ParseTree } @@ -24,6 +26,7 @@ import v2.std.node { Symbol, TypeNode, node_rebuild, + node_subtree_nodes, well_formed } import v2.std.compilers.sugar { @@ -44,6 +47,7 @@ import v2.compiler.namespace_graft { namespace_graft_is_graft_candidate } import v2.extdeps.languages.dag { + import_binding_rows_from_decl_node, parse_production_emitted_identity_optional } @@ -210,6 +214,45 @@ fn normalize_node_fold_children_ctx(n: Node, under_fn_decl: Bool) -> Outcome Outcome> { + fold_list( + xs: node_subtree_nodes(root: tree), + empty: Accepted { value: Empty, diagnostics: v2.std.diagnostic.None }, + cons: fn(acc, n) { + match acc { + Rejected { diagnostics: r } => Rejected { diagnostics: r } + Accepted { value: rows, diagnostics: _ } => + match parse_production_emitted_identity_optional(node: n) { + Absent => acc + Present { value: id } => + if id != ^dag_surface_import_decl { + acc + } else { + match import_binding_rows_from_decl_node(decl: n, module_root: tree) { + Rejected { diagnostics: r } => Rejected { diagnostics: r } + Accepted { value: fresh, diagnostics: _ } => + Accepted { + value: fold_list( + xs: fresh, + empty: rows, + cons: fn(a, row) { list_snoc_item(xs: a, item: row) } + ), + diagnostics: v2.std.diagnostic.None + } + } + } + } + } + } + ) +} + fn normalize(parse_tree: ParseTree) -> Outcome { match normalize_node(n: parse_tree) { Rejected { diagnostics: r } => Rejected { diagnostics: r } @@ -222,7 +265,17 @@ fn normalize(parse_tree: ParseTree) -> Outcome { Rejected { diagnostics: r } => Rejected { diagnostics: rejected_with_pending(pending: d1, rejected: r) } Accepted { value: markers, diagnostics: _ } => - admit_normalized_tree(root: grafted, test_markers: markers, diagnostics: d1) + match import_binding_capture(tree: parse_tree) { + Rejected { diagnostics: r } => + Rejected { diagnostics: rejected_with_pending(pending: d1, rejected: r) } + Accepted { value: bindings, diagnostics: _ } => + admit_normalized_tree( + root: grafted, + test_markers: markers, + import_bindings: bindings, + diagnostics: d1 + ) + } } } else { Rejected { diff --git a/src/v2/compiler/03_resolve.dag b/src/v2/compiler/03_resolve.dag index ca17b671a9e..d67248130c1 100644 --- a/src/v2/compiler/03_resolve.dag +++ b/src/v2/compiler/03_resolve.dag @@ -64,16 +64,12 @@ import v2.std.resolution_policy { default_name_resolution_policy } import v2.std.symbol_index { - GlobalBareHit, - GlobalBareLookupAmbiguous, - GlobalBareLookupUnbound, LexicalAmbiguous, LexicalBindingCandidate, LexicalHit, LexicalUnbound, SymbolIndex, empty_symbol_index, - symbol_index_global_unique_lookup, symbol_index_lexical_lookup, symbol_index_lookup } @@ -439,14 +435,17 @@ fn ambiguous_symbol_diagnostic(n: Node) -> Diagnostic { // whole path also hits absolutely -- and a reader repairing the reference needs to know which // one, because the repair differs (qualify the reference; rename one declaration; drop the // shadowing binder). The competing declarations ride WHERE THE LOOKUP RETAINED THEM: the lexical -// collector keeps every candidate's path; the global-bare index collapsed its binders to one -// GlobalBareAmbiguous state at fill time and has nothing to name, which this arm says by carrying -// nothing rather than by naming a list it does not have; the qualified case names the absolute -// path that competed. Collapsing all three to one bare SymbolIndexAtomAmbiguous was where the -// class and the candidates were dropped -- one symbol standing where the resolver had a list. +// collector keeps every candidate's path; the qualified case names the absolute path that +// competed. Collapsing them to one bare SymbolIndexAtomAmbiguous was where the class and the +// candidates were dropped -- one symbol standing where the resolver had a list. +// +// THE THIRD ARM IS GONE WITH ITS MECHANISM. AmbiguousOnGlobalBare reported an ambiguity found by +// searching the corpus for a spelling, and it was the one arm that could name NO competing +// declaration, because the bare index had already collapsed its binders at fill time. Deleting the +// search deleted the arm: every ambiguity the resolver can now reach was found among candidates it +// still holds, so "competing declarations where available" is no longer a caveat this type needs. type AmbiguousLookupClass = AmbiguousOnLexicalChain { candidates: FreeMonoid } - | AmbiguousOnGlobalBare | AmbiguousQualifiedHeadShadowsAbsolute { path: QualifiedName } fn ambiguous_lookup_class_diagnostic(n: Node, reason: Symbol) -> Diagnostic { @@ -492,11 +491,6 @@ fn ambiguous_symbol_diagnostics(n: Node, class: AmbiguousLookupClass) -> NonEmpt item: fatal ) } - AmbiguousOnGlobalBare => - NonEmptyDiagnostics { - head: ambiguous_lookup_class_diagnostic(n: n, reason: ^resolve_ambiguous_on_global_bare), - tail: [fatal] - } AmbiguousQualifiedHeadShadowsAbsolute { path: path } => NonEmptyDiagnostics { head: ambiguous_lookup_class_diagnostic(n: n, reason: ^resolve_ambiguous_qualified_head_shadows_absolute), @@ -552,23 +546,21 @@ fn lookup_symbol_index_atom_identity( } LexicalAmbiguous { candidates: candidates } => SymbolIndexAtomAmbiguous { class: AmbiguousOnLexicalChain { candidates: candidates } } - LexicalUnbound => - match ctx.policy { - NamespaceOnlyY => - match symbol_index_global_unique_lookup( - index: ctx.namespace.symbol_index, - name: name - ) { - GlobalBareHit { node: node, path: path } => - SymbolIndexAtomHit { - canonical: symbol_index_node_identity(node: node, fallback: name), - path: path - } - GlobalBareLookupAmbiguous => SymbolIndexAtomAmbiguous { class: AmbiguousOnGlobalBare } - GlobalBareLookupUnbound => SymbolIndexAtomUnbound - } - ImportScoped => SymbolIndexAtomUnbound - } + // NOTHING FOLLOWS AN EXHAUSTED CHAIN. This arm used to ask the corpus-wide bare-name index + // under NamespaceOnlyY, and that question has no answer it is entitled to give: a name the + // reference's own ancestor chain does not bind has no meaning AT THE REFERENCE, whatever else + // the corpus spells the same way. docs/plans/namespace-resolution-design.md section 13 deletes + // the tier by name -- "global_bare dies as a mechanism" -- and the reference model this corpus + // grounds lookup in agrees by ABSENCE: [basic.lookup.unqual] walks outward to the parent scope + // and, exhausted, the program is ill-formed; there is no clause that searches a whole program + // for a unique declaration. What replaces the tier is not a weaker answer but a BINDING the + // author wrote: an import is transmuted into a row at the importing position + // (v2.extdeps.languages.dag import_binding_rows_from_decl_node), so the names a module actually + // uses are ON its chain and the collector above finds them there. + // + // THE INDEX'S global_bare MAP SURVIVES, and only as what section 13 leaves it as: a migration + // oracle for the divergence census. No resolution path reaches it. + LexicalUnbound => SymbolIndexAtomUnbound } } diff --git a/src/v2/compiler/declaring_identity_spelling_census.dag b/src/v2/compiler/declaring_identity_spelling_census.dag index 0c912b10407..ca6cd7c2ecd 100644 --- a/src/v2/compiler/declaring_identity_spelling_census.dag +++ b/src/v2/compiler/declaring_identity_spelling_census.dag @@ -38,7 +38,7 @@ import v2.compiler.resolution_provenance { resolve_reference_sites } import v2.compiler.source_authority { SourceRootIngest } -import v2.compiler.symbol_index_fill { symbol_index_fill_module_roots } +import v2.compiler.symbol_index_fill { symbol_index_fill_module_root_nodes } // XL-3 PRODUCER: one mention, one spelling row, over the production ingest route. // @@ -115,7 +115,7 @@ fn spelling_census_over_roots(roots: FreeMonoid) -> DeclaringIdentitySpell DeclaringIdentitySpellingCensus { rows: fold_list( xs: resolve_reference_sites( - index: symbol_index_fill_module_roots(index: empty_symbol_index(), roots: roots), + index: symbol_index_fill_module_root_nodes(index: empty_symbol_index(), roots: roots), roster: module_roster_from_roots(roots: roots), sites: collected.sites ), diff --git a/src/v2/compiler/normalized_tree.dag b/src/v2/compiler/normalized_tree.dag index 8e5731399dc..645c82eb319 100644 --- a/src/v2/compiler/normalized_tree.dag +++ b/src/v2/compiler/normalized_tree.dag @@ -18,6 +18,7 @@ import std.algebra { Empty, FreeMonoid } import v2.std.algebra { fold_list, list_snoc_item } import v2.std.node { Node } import v2.std.declaration_marker { TestMarkerChannel, test_marker_channel_empty } +import v2.std.namespace_alias { AliasBindingRow, ModuleBindingSource } // A NormalizedTree is the ADMITTED product of normalize: a root whose own lowering left no wrapper // behind. It was an alias for Node until BL-1, which is why retention evidence reached resolve as a @@ -27,9 +28,19 @@ import v2.std.declaration_marker { TestMarkerChannel, test_marker_channel_empty // beside the root rather than inside it: `root` is byte-identical for a `test fn` and a plain `fn`, // and every consumer about Node structure reads `root` alone. Name resolution is the channel's // consumer, and refuses references to what it marks. +// THE THIRD FIELD IS HERE FOR THE SAME REASON THE SECOND IS. The module graft dissolves an import +// decl (v2.compiler.namespace_graft namespace_graft_unit_disposition, UnitDissolved) because an +// import is not a declaration of this module and has no place in its containment tree. That is +// right, and it erases the one fact docs/plans/namespace-resolution-design.md section 13 needs from +// it: `import m.p { A }` BINDS `A` at this module's position. A `test` marker is erased by lowering +// for an equally good reason and is carried here rather than recovered downstream; an import +// binding is the same shape of fact and is carried the same way. Recovering it after the graft is +// not an option -- the node is gone -- and re-reading the source at the index would be a second +// read of a fact this carrier already passed. type NormalizedTree sole_constructor { root: Node, test_markers: TestMarkerChannel, + import_bindings: FreeMonoid, } // Rung: ACCEPTED REFINEMENT WITH EXECUTING REFUSAL, not structural impossibility. The invalid state @@ -49,6 +60,7 @@ fn normalized_tree_retention_diagnostic(root: Node) -> Diagnostic { fn admit_normalized_tree( root: Node, test_markers: TestMarkerChannel, + import_bindings: FreeMonoid, diagnostics: Diagnostics ) -> Outcome { if body_lowering_diagnostics_carry_wrapper_retention(d: diagnostics) { @@ -59,10 +71,38 @@ fn admit_normalized_tree( ) } } else { - Accepted { value: NormalizedTree { root: root, test_markers: test_markers }, diagnostics: diagnostics } + Accepted { + value: NormalizedTree { + root: root, + test_markers: test_markers, + import_bindings: import_bindings + }, + diagnostics: diagnostics + } } } +// Projection out of the sealed carrier for the symbol-index fill, which needs both halves of what a +// module binds and can import neither this carrier nor the stages beneath it. Order-preserving for +// the same reason the Node projection is. +fn normalized_tree_roots_to_binding_sources( + roots: FreeMonoid +) -> FreeMonoid { + fold_list( + xs: roots, + empty: Empty, + cons: fn(acc, tree) { + list_snoc_item( + xs: acc, + item: ModuleBindingSource { + root: tree.root, + import_bindings: tree.import_bindings + } + ) + } + ) +} + // Projection out of the sealed carrier for consumers genuinely about Node structure, not admission // — the symbol-index fill, for one. Order-preserving: a symbol index built from a reordered root // list is a different index. @@ -74,9 +114,10 @@ fn normalized_tree_roots_to_nodes(roots: FreeMonoid) -> FreeMono ) } -// THE UNMARKED DOOR, AND ITS NAME SAYS SO. Its roots arrive as already-lowered Nodes, from which the -// authored `test` marker cannot be recovered (lowering erases it by design), so every root is admitted -// with an empty marker channel. That is a statement about the caller's input, not a default: a caller +// THE UNMARKED DOOR, AND ITS NAME SAYS SO. Its roots arrive as already-lowered Nodes, from which +// neither the authored `test` marker nor the module's import decls can be recovered (lowering erases +// the first by design, the graft dissolves the second), so every root is admitted with an empty +// marker channel and no import bindings. That is a statement about the caller's input, not a default: a caller // holding source goes through v2.compiler.normalize, which captures the channel from the parse tree. // // The plural door: the same decision per element, refusing as a whole so a partially admitted list @@ -98,7 +139,12 @@ fn admit_unmarked_normalized_roots( match acc { Rejected { diagnostics: r } => Rejected { diagnostics: r } Accepted { value: admitted, diagnostics: _ } => - match admit_normalized_tree(root: root, test_markers: test_marker_channel_empty(), diagnostics: diagnostics) { + match admit_normalized_tree( + root: root, + test_markers: test_marker_channel_empty(), + import_bindings: Empty, + diagnostics: diagnostics + ) { Rejected { diagnostics: r } => Rejected { diagnostics: r } Accepted { value: one, diagnostics: _ } => Accepted { diff --git a/src/v2/compiler/repair_input_origin_roster.dag b/src/v2/compiler/repair_input_origin_roster.dag index b68b1ecfc2e..4f4337635bb 100644 --- a/src/v2/compiler/repair_input_origin_roster.dag +++ b/src/v2/compiler/repair_input_origin_roster.dag @@ -41,7 +41,7 @@ import v2.compiler.resolution_provenance { module_roster_from_roots, resolve_reference_sites } -import v2.compiler.symbol_index_fill { symbol_index_fill_module_roots } +import v2.compiler.symbol_index_fill { symbol_index_fill_module_root_nodes } // NAMESPACE-XL STAGE XL-0: THE REPAIR-INPUT ORIGIN DENOMINATOR, ON THE v2 ROUTE. // @@ -186,7 +186,7 @@ fn repair_input_origin_roster_over_roots( let collected = collect_reference_sites(roots: roots) RepairInputOriginRoster { carriers: repair_input_origin_roster_from_sites( - index: symbol_index_fill_module_roots(index: empty_symbol_index(), roots: roots), + index: symbol_index_fill_module_root_nodes(index: empty_symbol_index(), roots: roots), module_roster: module_roster_from_roots(roots: roots), sites: collected.sites ), diff --git a/src/v2/compiler/symbol_index_fill.dag b/src/v2/compiler/symbol_index_fill.dag index 4d46f089872..d592f9c5218 100644 --- a/src/v2/compiler/symbol_index_fill.dag +++ b/src/v2/compiler/symbol_index_fill.dag @@ -32,11 +32,14 @@ import v2.std.node { import v2.std.qualified_name { QualifiedName, qualified_name_snoc } import v2.std.symbol_index { SymbolIndex, + symbol_index_bind_at, symbol_index_insert, symbol_index_lookup } +import v2.std.namespace_alias { AliasBindingRow, ModuleBindingSource } import v2.extdeps.languages.dag { alias_binding_row_from_decl_node, + import_binding_rows_from_decl_node, dag_surface_module_header_metadata_edge, namespace_graft_spine_segment_edge_optional, qualified_name_from_module_node @@ -201,6 +204,36 @@ fn disj_variant_counts_in_module(root: Node) -> Map { ) } +// ONE ROW, ONE BINDING, WHATEVER SURFACE FORM WROTE IT. An `alias` decl and one named item of an +// `import` both arrive here as an AliasBindingRow, so this is the single place a name is bound at a +// position that is not its declaration's own. It binds through symbol_index_bind_at rather than +// symbol_index_insert because a row may land on a position a declaration already holds -- a module +// that declares `N` and also imports `N` -- and the map has one slot: an insert there would let the +// fill order pick the meaning silently. +// +// A ROW WHOSE TARGET IS NOT IN THE INDEX BINDS NOTHING, and that is not a swallowed error. The +// refusal docs/plans/namespace-resolution-design.md section 13 asks for is raised AT THE REFERENCE +// SITE, not at the declaration: with no binding at the importing position, a reference to the name +// is off its own ancestor chain and the resolver refuses it, located, with the name the source +// actually wrote. Refusing here instead would report the importing MODULE as the failing subject +// and lose every reference site inside it. +fn symbol_index_fill_binding_row( + index: SymbolIndex, + module_qn: QualifiedName, + row: AliasBindingRow +) -> SymbolIndex { + match symbol_index_lookup(index: index, qualified_path: row.target) { + Present { value: resolved } => + symbol_index_bind_at( + index: index, + binding_path: qualified_name_snoc(qn: module_qn, segment: row.binding), + declaring_path: row.target, + resolved: resolved + ) + Absent => index + } +} + fn symbol_index_fill_alias_binding( index: SymbolIndex, module_qn: QualifiedName, @@ -210,15 +243,26 @@ fn symbol_index_fill_alias_binding( match alias_binding_row_from_decl_node(decl: decl, module_root: root) { Rejected { diagnostics: _ } => index Accepted { value: row, diagnostics: _ } => - match symbol_index_lookup(index: index, qualified_path: row.target) { - Present { value: resolved } => - symbol_index_insert( - index: index, - qualified_path: qualified_name_snoc(qn: module_qn, segment: row.binding), - resolved: resolved - ) - Absent => index - } + symbol_index_fill_binding_row(index: index, module_qn: module_qn, row: row) + } +} + +fn symbol_index_fill_import_binding( + index: SymbolIndex, + module_qn: QualifiedName, + root: Node, + decl: Node +) -> SymbolIndex { + match import_binding_rows_from_decl_node(decl: decl, module_root: root) { + Rejected { diagnostics: _ } => index + Accepted { value: rows, diagnostics: _ } => + fold_list( + xs: rows, + empty: index, + cons: fn(acc, row) { + symbol_index_fill_binding_row(index: acc, module_qn: module_qn, row: row) + } + ) } } @@ -231,8 +275,13 @@ fn symbol_index_fill_alias_bindings( node_subtree_nodes(root: root), init: index, f: fn(acc, n) { - symbol_index_fill_alias_binding( - index: acc, + symbol_index_fill_import_binding( + index: symbol_index_fill_alias_binding( + index: acc, + module_qn: module_qn, + root: root, + decl: n + ), module_qn: module_qn, root: root, decl: n @@ -274,19 +323,65 @@ fn symbol_index_fill_unique_variant_aliases( ) } -fn symbol_index_fill_module_root(index: SymbolIndex, root: Node) -> SymbolIndex { +// PASS A -- WHAT THIS ROOT DECLARES. Every path here is rooted at this module's own qualified name, +// so no other root can write it and the result does not depend on when this root is visited. +fn symbol_index_fill_module_declarations(index: SymbolIndex, root: Node) -> SymbolIndex { match qualified_name_from_module_node(root: root) { Accepted { value: module_qn, diagnostics: _ } => - symbol_index_fill_alias_bindings( - index: symbol_index_fill_unique_variant_aliases( - index: symbol_index_fill_containment_node( - index: index, - path: module_qn, - node: root - ), + symbol_index_fill_unique_variant_aliases( + index: symbol_index_fill_containment_node( + index: index, + path: module_qn, + node: root + ), + module_qn: module_qn, + root: root + ) + Rejected { diagnostics: _ } => index + } +} + +// PASS B -- WHAT THIS ROOT BINDS FROM ELSEWHERE. Every row here reads ANOTHER root's declarations +// out of the index, so it can only be run once every root's pass A has been. The two sources of +// rows differ only in where they SURVIVED: an `alias` decl is grafted into the tree as an ordinary +// named unit and is still readable from the root, while an import decl was dissolved by the graft +// and its rows ride on the carrier (v2.compiler.normalized_tree NormalizedTree import_bindings). +// They are the same kind of row and bind by the same call. +fn symbol_index_fill_module_bindings(index: SymbolIndex, tree: ModuleBindingSource) -> SymbolIndex { + match qualified_name_from_module_node(root: tree.root) { + Accepted { value: module_qn, diagnostics: _ } => + fold_list( + xs: tree.import_bindings, + empty: symbol_index_fill_alias_bindings( + index: index, module_qn: module_qn, - root: root + root: tree.root ), + cons: fn(acc, row) { + symbol_index_fill_binding_row(index: acc, module_qn: module_qn, row: row) + } + ) + Rejected { diagnostics: _ } => index + } +} + +fn symbol_index_fill_module_tree(index: SymbolIndex, tree: ModuleBindingSource) -> SymbolIndex { + symbol_index_fill_module_bindings( + index: symbol_index_fill_module_declarations(index: index, root: tree.root), + tree: tree + ) +} + +// THE NODE-ONLY DOOR, AND ITS NAME SAYS WHAT IT CANNOT DO. A caller holding only a grafted Node has +// no import rows to offer -- the graft dissolved them and they are not recoverable from what is +// left -- so this fills declarations and the alias rows still present in the tree, and no import +// bindings. That is a statement about the input, not a default: a caller holding a NormalizedTree +// goes through symbol_index_fill_module_tree and gets all three. +fn symbol_index_fill_module_root(index: SymbolIndex, root: Node) -> SymbolIndex { + match qualified_name_from_module_node(root: root) { + Accepted { value: module_qn, diagnostics: _ } => + symbol_index_fill_alias_bindings( + index: symbol_index_fill_module_declarations(index: index, root: root), module_qn: module_qn, root: root ) @@ -294,15 +389,56 @@ fn symbol_index_fill_module_root(index: SymbolIndex, root: Node) -> SymbolIndex } } -fn symbol_index_fill_module_roots( +// TWO PASSES OVER THE ROOTS, BECAUSE ONE PASS MAKES FILE ORDER DECIDE WHAT A REFERENCE MEANS. +// A binding row reads its target out of the index, so under a single per-root pass a row pointing +// at a root not yet visited finds nothing and binds nothing, while the same row in a corpus listed +// the other way round binds fine. That is the corpus answering a resolution question with the order +// its files were walked in, which docs/plans/namespace-resolution-design.md section 13 rules out as +// a class -- and it is the quiet kind of order dependence, because the loser is an absence rather +// than a wrong answer. Separating the passes removes the dependence by CONSTRUCTION rather than by +// sorting the roots into some order that happens to work: after pass A every declaration any row +// could name is present, so pass B's result is the same set for every permutation of the input. +// THE PLURAL NODE-ONLY DOOR. Same statement as the singular one -- no import rows exist in a +// grafted Node -- and the same two passes, so a census over a root list does not depend on the +// order the list came in either. +fn symbol_index_fill_module_root_nodes( index: SymbolIndex, roots: FreeMonoid ) -> SymbolIndex { fold_list( xs: roots, - empty: index, + empty: fold_list( + xs: roots, + empty: index, + cons: fn(acc, root) { + symbol_index_fill_module_declarations(index: acc, root: root) + } + ), cons: fn(acc, root) { - symbol_index_fill_module_root(index: acc, root: root) + match qualified_name_from_module_node(root: root) { + Accepted { value: module_qn, diagnostics: _ } => + symbol_index_fill_alias_bindings(index: acc, module_qn: module_qn, root: root) + Rejected { diagnostics: _ } => acc + } + } + ) +} + +fn symbol_index_fill_module_roots( + index: SymbolIndex, + roots: FreeMonoid +) -> SymbolIndex { + fold_list( + xs: roots, + empty: fold_list( + xs: roots, + empty: index, + cons: fn(acc, tree) { + symbol_index_fill_module_declarations(index: acc, root: tree.root) + } + ), + cons: fn(acc, tree) { + symbol_index_fill_module_bindings(index: acc, tree: tree) } ) } diff --git a/src/v2/extdeps/languages/dag.dag b/src/v2/extdeps/languages/dag.dag index 6184bb8c6df..4b08d6151db 100644 --- a/src/v2/extdeps/languages/dag.dag +++ b/src/v2/extdeps/languages/dag.dag @@ -5550,6 +5550,95 @@ fn alias_binding_row_target_from_decl(decl: Node, module_root: Node) -> Outcome< } } +// AN IMPORT IS AN ALIAS ROW PER NAMED ITEM, AND THAT IS THE WHOLE OF THE TRANSMUTATION. +// docs/plans/namespace-resolution-design.md section 13 rules that `import` becomes a binding node at +// the IMPORTING position -- "the source of truth is the walk's resolved target" -- so +// `import m.p { A, B }` in module S is two rows, S.A -> m.p.A and S.B -> m.p.B, carried in the SAME +// AliasBindingRow type an `alias` decl produces. Minting a second row type here would give one +// concept two names (DESIGN section 3) and would let the two forms drift apart in the one place +// they must not: what a binding at a position MEANS. +// +// THE BLOCK IS THE BINDING SET, NOT THE MODULE. A block-less `import m.p` yields ZERO rows rather +// than every name m.p declares. Admitting the module wholesale is a using-directive, which the +// cited reference model (extdeps.languages.cpp.name_lookup, [basic.lookup.udir]) carries as an +// EXCLUDED form and which section 13's edit-stability invariant forbids for the same reason: +// a name added to m.p would silently start binding in S. Zero rows is not a silent drop -- the +// reference that wanted the name is then off its own chain and refuses, located, at the reference +// site, which is where section 13 puts the refusal. +// THE IMPORTED MODULE'S PATH, READ IN WHICHEVER FORM THE TREE IS IN. A parsed tree still carries +// the grammar's capture for the qualified name; a normalized one carries the emitted edge +// `dag_surface_import_decl_qualified_name` instead. Both are this row's authority for the same fact, +// and reading only one of them is how a reader looks correct and silently returns nothing on the +// tree it is actually given. The alias form's reader cannot stand in here: its fallback names +// `dag_surface_alias_decl_target`, an edge no import decl has. +fn import_decl_target_module(decl: Node, module_root: Node) -> Outcome { + match parse_subtree_find_production_captured( + root: decl, + emitted: ^dag_surface_qualified_name + ) { + ParseSubtreeFound { captured: qn_capture } => + match parse_qualified_name_segments_from_capture(qn_capture: qn_capture, root: module_root) { + Rejected { diagnostics: d } => Rejected { diagnostics: d } + Accepted { value: segments, diagnostics: _ } => + qualified_name_from_symbol_segments(segments: segments, root: module_root) + } + ParseSubtreeAbsent => + match find_named_child(root: decl, name: ^dag_surface_import_decl_qualified_name) { + Rejected { diagnostics: d } => Rejected { diagnostics: d } + Accepted { value: qn_node, diagnostics: _ } => + qualified_name_from_node(root: qn_node) + } + } +} + +fn import_binding_rows_from_decl_node( + decl: Node, + module_root: Node +) -> Outcome> { + match parse_production_emitted_identity_optional(node: decl) { + Absent => + outcome_rejected(Diagnostic { + reason: ^import_binding_row_not_import_decl, + at: node_locus(node: decl), + correction: Unavailable { reason: ExternalContractUnknown } + }) + Present { value: id } => + if id != ^dag_surface_import_decl { + outcome_rejected(Diagnostic { + reason: ^import_binding_row_not_import_decl, + at: node_locus(node: decl), + correction: Unavailable { reason: ExternalContractUnknown } + }) + } else { + bind_outcome( + o: import_decl_target_module(decl: decl, module_root: module_root), + f: fn(target_module) { + bind_outcome( + o: parse_import_block_idents(import_decl_node: decl, root: module_root), + f: fn(names) { + outcome_accepted( + value: fold_list( + xs: names, + empty: Empty, + cons: fn(acc, name) { + list_snoc_item( + xs: acc, + item: AliasBindingRow { + binding: name, + target: qualified_name_snoc(qn: target_module, segment: name) + } + ) + } + ) + ) + } + ) + } + ) + } + } +} + fn alias_binding_row_from_decl_node(decl: Node, module_root: Node) -> Outcome { match parse_production_emitted_identity_optional(node: decl) { Present { value: id } => diff --git a/src/v2/std/namespace_alias.dag b/src/v2/std/namespace_alias.dag index c4e969efc4e..d92cead284e 100644 --- a/src/v2/std/namespace_alias.dag +++ b/src/v2/std/namespace_alias.dag @@ -1,6 +1,7 @@ module v2.std.namespace_alias -import v2.std.node { Symbol } +import std.algebra { FreeMonoid } +import v2.std.node { Node, Symbol } import v2.std.qualified_name { QualifiedName } // Namespace alias surface row (docs/plans/namespace-resolution-design.md §13): alias = @@ -13,3 +14,15 @@ type AliasBindingRow { binding: Symbol target: QualifiedName } + +// WHAT THE SYMBOL INDEX NEEDS FROM ONE MODULE IN ORDER TO BIND IT, and the reason it is a pair +// rather than two parallel lists. The declarations come from the grafted root; the import rows +// cannot, because the graft dissolved the decls that carried them, so they arrive from the carrier +// that captured them at normalize. Holding the two in one value keeps them joined by CONSTRUCTION: +// two lists walked in step would be joined by position, and a reordering of either would silently +// bind one module's imports into another module's namespace. The type lives here, beside the row it +// carries, because the index fill and the normalized-tree carrier cannot import each other. +type ModuleBindingSource { + root: Node + import_bindings: FreeMonoid +} diff --git a/src/v2/std/symbol_index.dag b/src/v2/std/symbol_index.dag index ff23a2a1b7f..12ba35d99bc 100644 --- a/src/v2/std/symbol_index.dag +++ b/src/v2/std/symbol_index.dag @@ -1,10 +1,12 @@ module v2.std.symbol_index -import std.algebra { Empty, FreeMonoid } +import std.algebra { Cons, Empty, FreeMonoid } import v2.std.algebra { HeadAbsent, HeadFound, + contains, + fold_list, is_empty, length, list_head, @@ -52,15 +54,35 @@ type LexicalLookup | LexicalAmbiguous { candidates: FreeMonoid } | LexicalUnbound +// A BINDING POSITION IS NOT THE SAME FACT AS A DECLARING POSITION, AND THE THIRD FIELD IS WHERE +// THEY COME APART. `entries` answers "what declaration lives at this qualified path" -- one +// declaration, because a path names a place in the containment tree. A BINDING position is where a +// reference may see a name, and a transmuted import binds a name at the importing module's position +// whose declaration lives somewhere else entirely. Two things can therefore claim one binding +// position -- a module that imports `N` from two places, or declares `N` and also imports it -- and +// a map keyed by that position has exactly one slot, so the second writer would silently take it. +// That is nearest-wins at distance zero, which docs/plans/namespace-resolution-design.md section 13 +// rejects as a class, and it is invisible: the loser leaves no trace to refuse over. +// +// SO CLAIMANTS ARE COUNTED BY DECLARING PATH, NOT BY NODE. The obvious cheaper test -- treat a +// second writer as the same binding when the node it carries equals the one already there -- is +// wrong, and wrong in the direction that reports a green: two DISTINCT declarations in two modules +// are routinely structurally identical (`type NcrTwin = Bool` in each of two namespaces builds the +// same node), so node equality silently merges two binders into one and the ambiguity disappears. +// It was measured doing exactly that. A declaration's identity is WHERE IT IS DECLARED, so that is +// what this map holds: every declaring path claiming a binding position, the first one included, +// which is also what the refusal must name. type SymbolIndex { entries: Map global_bare: Map + bound_declarings: Map> } fn empty_symbol_index() -> SymbolIndex { SymbolIndex { entries: empty_map(), - global_bare: empty_map() + global_bare: empty_map(), + bound_declarings: empty_map() } } @@ -92,7 +114,8 @@ fn symbol_index_track_global_bare( m: index.global_bare, key: leaf, value: GlobalBareAmbiguous - ) + ), + bound_declarings: index.bound_declarings } } Absent => @@ -102,7 +125,8 @@ fn symbol_index_track_global_bare( m: index.global_bare, key: leaf, value: GlobalBareUnique { node: resolved, path: qualified_path } - ) + ), + bound_declarings: index.bound_declarings } } Rejected { diagnostics: _ } => index @@ -117,7 +141,8 @@ fn symbol_index_insert(index: SymbolIndex, qualified_path: QualifiedName, resolv key: qualified_path, value: resolved ), - global_bare: index.global_bare + global_bare: index.global_bare, + bound_declarings: index.bound_declarings } symbol_index_track_global_bare( index: with_entry, @@ -146,23 +171,110 @@ fn symbol_index_try_lexical_at( symbol_index_lookup(index: index, qualified_path: candidate_path) } +// BINDING A NAME AT A POSITION IS A DIFFERENT OPERATION FROM RECORDING A DECLARATION, and this is +// the one writer for it. `symbol_index_insert` answers "this declaration lives at this path", where +// binding position and declaring position are the same thing and a second writer at one path is a +// containment-tree impossibility. A BINDING may be minted at a position that is not the +// declaration's own -- an alias row, a transmuted import -- and there two claimants at one position +// are ordinary. The first claimant takes the slot and every later DIFFERENT claimant is recorded as +// contested rather than dropped, because the alternative is that the index answers with whichever +// declaration happened to be written last: a silent pick made by fill order, which is the class +// docs/plans/namespace-resolution-design.md section 13 refuses. Re-binding the SAME declaration is +// idempotent and contests nothing -- fill is run over roots whose bindings legitimately repeat, and +// a position contested with itself would refuse every reference to a name bound exactly once. +fn symbol_index_bind_at( + index: SymbolIndex, + binding_path: QualifiedName, + declaring_path: QualifiedName, + resolved: Node +) -> SymbolIndex { + let prior = symbol_index_bound_declarings(index: index, binding_path: binding_path) + if contains(xs: prior, item: declaring_path, eq: fn(a, b) { a == b }) { + index + } else { + let recorded = SymbolIndex { + entries: index.entries, + global_bare: index.global_bare, + bound_declarings: map_insert( + m: index.bound_declarings, + key: binding_path, + value: list_snoc_item(xs: prior, item: declaring_path) + ) + } + if is_empty(xs: prior) { + symbol_index_insert(index: recorded, qualified_path: binding_path, resolved: resolved) + } else { + recorded + } + } +} + +fn symbol_index_bound_declarings( + index: SymbolIndex, + binding_path: QualifiedName +) -> FreeMonoid { + match v2.std.collection.map_get(index.bound_declarings, binding_path) { + Accepted { value: opt, diagnostics: _ } => match opt { + Present { value: declarings } => declarings + Absent => Empty + } + Rejected { diagnostics: _ } => Empty + } +} + +// EVERY CLAIMANT OF ONE BINDING POSITION, NOT THE ONE THAT WON THE SLOT. The collector below walks +// positions, and at each position it must learn how MANY things bind the name there, not merely +// whether something does -- a position that answers "one" while two declarations claim it is the +// silent pick this index exists not to make. The occupant is named by the binding path, which is a +// spelling the author can actually write; each contested claimant is named by its DECLARING path, +// which is what a reader must see to tell the two apart (naming the binding path twice would report +// one spelling as competing with itself). A contested claimant whose declaration has since left the +// index contributes nothing rather than a candidate with no node behind it. +fn symbol_index_candidates_at( + index: SymbolIndex, + position: QualifiedName, + name: Symbol +) -> FreeMonoid { + let binding_path = qualified_name_snoc(qn: position, segment: name) + let bound = symbol_index_bound_declarings(index: index, binding_path: binding_path) + if is_empty(xs: bound) { + match symbol_index_lookup(index: index, qualified_path: binding_path) { + Present { value: node } => + Cons { + head: LexicalBindingCandidate { path: binding_path, node: node }, + tail: Empty + } + Absent => Empty + } + } else { + fold_list( + xs: bound, + empty: Empty, + cons: fn(acc, declaring) { + match symbol_index_lookup(index: index, qualified_path: declaring) { + Present { value: node } => + list_snoc_item( + xs: acc, + item: LexicalBindingCandidate { path: declaring, node: node } + ) + Absent => acc + } + } + ) + } +} + fn symbol_index_lexical_collect( index: SymbolIndex, position: QualifiedName, name: Symbol, acc: FreeMonoid ) -> FreeMonoid { - let with_current = match symbol_index_try_lexical_at(index: index, position: position, name: name) { - Present { value: node } => - list_snoc_item( - xs: acc, - item: LexicalBindingCandidate { - path: qualified_name_snoc(qn: position, segment: name), - node: node - } - ) - Absent => acc - } + let with_current = fold_list( + xs: symbol_index_candidates_at(index: index, position: position, name: name), + empty: acc, + cons: fn(a, candidate) { list_snoc_item(xs: a, item: candidate) } + ) if is_empty(xs: position) { with_current } else { diff --git a/src/v2/test/claim/declaring_identity_spelling/production_ingest_test.dag b/src/v2/test/claim/declaring_identity_spelling/production_ingest_test.dag index d56836c9df6..f7f82596e1e 100644 --- a/src/v2/test/claim/declaring_identity_spelling/production_ingest_test.dag +++ b/src/v2/test/claim/declaring_identity_spelling/production_ingest_test.dag @@ -15,7 +15,7 @@ import v2.compiler.resolution_provenance { resolve_reference_provenance } import v2.compiler.source_authority { DagSourceReadWitness, SourceRootIngest } -import v2.compiler.symbol_index_fill { symbol_index_fill_module_roots } +import v2.compiler.symbol_index_fill { symbol_index_fill_module_root_nodes } import v2.extdeps.languages.dag { dag_language_model } import v2.std.algebra { fold_list, is_empty } import v2.std.artifact { Artifact, SourceFile } @@ -315,7 +315,7 @@ fn value_outside_call_roots() -> Optional> { fn marker_site_fill_lookup_is_present(roots: FreeMonoid) -> Bool { let collected = collect_reference_sites(roots: roots) - let index = symbol_index_fill_module_roots(index: empty_symbol_index(), roots: roots) + let index = symbol_index_fill_module_root_nodes(index: empty_symbol_index(), roots: roots) fold_list( xs: collected.sites, empty: false, diff --git a/src/v2/test/claim/native_route/native_refusal_detail_test.dag b/src/v2/test/claim/native_route/native_refusal_detail_test.dag index 134a2d31215..6235f720061 100644 --- a/src/v2/test/claim/native_route/native_refusal_detail_test.dag +++ b/src/v2/test/claim/native_route/native_refusal_detail_test.dag @@ -314,15 +314,23 @@ test fn shadowed_reference_row_names_lookup_class_and_both_competing_declaration } } -// THE GLOBAL-BARE CLASS IS NAMED AND NO CANDIDATE LIST IS FABRICATED. The bare index collapsed its -// binders at fill time, so "where available" is honestly "not here": the class row says which -// lookup was ambiguous and the chain carries zero competing-declaration rows. -test fn global_bare_collision_row_names_its_lookup_class_without_fabricating_candidates_holds() -> Bool { +// A BARE NAME TWO MODULES SPELL, BOUND ON NO CHAIN, IS UNBOUND -- NOT AMBIGUOUS. This arm used to +// assert the opposite for this exact fixture: the resolver searched the corpus for the spelling, +// found two, and refused as ambiguous while able to name NEITHER binder, because the bare index had +// collapsed them at fill time. docs/plans/namespace-resolution-design.md section 13 deleted that +// search, so the question this fixture asks has changed answer, and the arm moves with it rather +// than retiring: `v2.ndq.user` binds `lone_value` nowhere, so it has no meaning here at all, and +// what the corpus spells elsewhere is not evidence about this reference. +// +// IT STAYS ENROLLED BECAUSE IT IS THE DISCRIMINATOR FOR THE DELETION (DESIGN section 4b(4)). +// Reinstating a corpus-wide bare-name search moves this arm back -- to ambiguous if the search +// refuses over the spelling, to resolved if it picks -- and the two negative clauses below say +// which wrong answer would be reported, so neither can pass quietly. +test fn a_bare_name_bound_on_no_chain_is_unbound_not_ambiguous_holds() -> Bool { match ndp_collision_refusal() { Absent => false Present { value: d } => - diagnostics_fatal_reason(d: d) == ^resolve_reason_ambiguous_symbol - && ndp_chain_has_reason(d: d, reason: ^resolve_ambiguous_on_global_bare) + diagnostics_fatal_reason(d: d) == ^resolve_reason_unbound_symbol && !ndp_chain_has_reason(d: d, reason: ^resolve_ambiguous_on_lexical_chain) && ndp_competing_declaration_count(d: d) == 0 } diff --git a/src/v2/test/claim/provenance/cross_file_provenance_test.dag b/src/v2/test/claim/provenance/cross_file_provenance_test.dag index a27932654aa..6a88cb0602d 100644 --- a/src/v2/test/claim/provenance/cross_file_provenance_test.dag +++ b/src/v2/test/claim/provenance/cross_file_provenance_test.dag @@ -82,7 +82,7 @@ data cfp_roots: FreeMonoid = Cons { tail: Cons { head: cfp_consumer_root, tail: Empty } } -data cfp_index: SymbolIndex = symbol_index_fill_module_roots( +data cfp_index: SymbolIndex = symbol_index_fill_module_root_nodes( index: empty_symbol_index(), roots: cfp_roots ) diff --git a/src/v2/test/claim/provenance/loaded_carrier_receipts_test.dag b/src/v2/test/claim/provenance/loaded_carrier_receipts_test.dag index b173ec411b9..5d29eb02904 100644 --- a/src/v2/test/claim/provenance/loaded_carrier_receipts_test.dag +++ b/src/v2/test/claim/provenance/loaded_carrier_receipts_test.dag @@ -3,7 +3,7 @@ module v2.test.claim.provenance.loaded_carrier_receipts import v2.compiler.program_assembly { module_roots_from_source_root_ingest } import v2.compiler.normalized_tree { NormalizedTree } import v2.compiler.source_authority { DagSourceReadWitness, SourceRootIngest } -import v2.compiler.symbol_index_fill { symbol_index_fill_module_roots } +import v2.compiler.symbol_index_fill { symbol_index_fill_module_root_nodes } import v2.extdeps.languages.dag { dag_language_model, qualified_name_from_module_node @@ -104,7 +104,7 @@ fn lcr_root_nodes() -> FreeMonoid { } fn lcr_index() -> SymbolIndex { - symbol_index_fill_module_roots(index: empty_symbol_index(), roots: lcr_root_nodes()) + symbol_index_fill_module_root_nodes(index: empty_symbol_index(), roots: lcr_root_nodes()) } fn lcr_root_named_step(acc: FreeMonoid, root: Node, wanted: QualifiedName) -> FreeMonoid { diff --git a/src/v2/test/claim/resolve/namespace_candidate_rule_test.dag b/src/v2/test/claim/resolve/namespace_candidate_rule_test.dag new file mode 100644 index 00000000000..224c4565c7a --- /dev/null +++ b/src/v2/test/claim/resolve/namespace_candidate_rule_test.dag @@ -0,0 +1,476 @@ +module v2.test.claim.resolve.namespace_candidate_rule + +import v2.compiler.compile { + NativeCensusModule, + NativeCensusModuleFileRefused, + NativeCensusModuleResolveRefused, + NativeCensusModuleResolved, + NativeCensusModuleOutcome, + NativeTestContext, + native_census_module_resolution, + native_census_modules, + native_lane_file_refusal_index, + native_test_context_from_ingest +} +import v2.compiler.native_test_vocabulary { NativeTestFileRefusal } +import v2.compiler.source_authority { DagSourceReadWitness, SourceRootIngest } +import v2.std.algebra { contains, fold_list, for_all, length } +import v2.std.collection { Map } +import v2.std.integer { Int } +import std.algebra { Cons, Empty, FreeMonoid } +import v2.std.artifact { Artifact, SourceFile } +import extdeps.communication.medium { Lossless, Medium } +import v2.std.cross_tree.import_model { DagTree } +import v2.std.diagnostic { + Accepted, + DeclarationLocus, + Diagnostic, + InvariantPortLocus, + NodeLocus, + NonEmptyDiagnostics, + Rejected, + SourcePortLocus, + Textual, + diagnostics_fatal_reason +} +import v2.std.live_tree { LiveTreeDisposition, SubstrateInputsOnly } +import v2.std.logic { Bool } +import v2.std.node { Symbol } +import v2.std.optional { Absent, Optional, Present, optional_absent, optional_present } +import v2.std.qualified_name { QualifiedName, qualified_name_from_dotted_string } +import v2.std.text { String } + +data live_tree_disposition: LiveTreeDisposition = SubstrateInputsOnly + +// THE SUBJECT IS THE CANDIDATE RULE ITSELF: which declarations a reference is PERMITTED to see. +// docs/plans/namespace-resolution-design.md section 13 rules that they come from the reference's own +// ancestor chain and from nothing else -- "fallback chains -- nearest-wins, global-unique-fallback, +// first-hit, any silent-pick -- are rejected as a class" -- and the reference model this corpus +// grounds that in (extdeps.languages.cpp.name_lookup, clause 6.5 [basic.lookup]) has no rule that +// searches a whole program for a unique declaration either. The departure gunbc declares on top of +// it is strictness: C++ takes the nearest scope's declarations and stops, gunbc refuses when two +// scopes on the chain bind the name. +// +// THE SOURCES ARE SUPPLIED AND THE ROUTE IS REAL, the split DESIGN section 3's standing rule asks +// for. The boundary these claims discriminate is name resolution, so its INPUT -- source bytes -- +// is constructed, and everything above it is the production fold: native_test_context_from_ingest +// tokenizes, parses and normalizes, and native_census_module_resolution runs the same resolver +// under the same NamespaceOnlyY policy the native lane runs. No claim here builds a Node tree. +// +// WHY FIVE MODULES AND NOT ONE. Every arm of the rule is only discriminating against a corpus where +// the same SPELLING exists twice: a single-module fixture resolves identically under the rule and +// under the global spelling search the rule replaces, so it would be green either way and would +// establish nothing. Two homes declare `NcrTwin`, and the three consumers differ only in what they +// BIND -- one home, neither home, both homes -- which is exactly the variable the rule reads. + +data ncr_home_a_source: String = "module v2.ncr_home_a\n\nimport v2.std.logic { Bool }\n\ntype NcrTwin = Bool\n" + +data ncr_home_b_source: String = "module v2.ncr_home_b\n\nimport v2.std.logic { Bool }\n\ntype NcrTwin = Bool\n" + +// THE POSITIVE SUBJECT. `NcrTwin` is declared in two sibling namespaces and this module binds ONE of +// them, so the reference has exactly one candidate on its chain and resolves -- while a search over +// the corpus by spelling has two and can only pick or refuse. Containment decides it; the spelling +// never could. +data ncr_importer_source: String = "module v2.ncr_importer\n\nimport v2.std.logic { Bool }\nimport v2.ncr_home_a { NcrTwin }\n\nfn ncr_use(p: NcrTwin) -> Bool { p }\n" + +// THE GENUINE AMBIGUITY, AND IT IS GENUINE IN THE RULE'S OWN TERMS: two declarations are bound at +// ONE position by two import rows. This is the case that must still refuse, and it is a different +// fact from the corpus containing two `NcrTwin`s -- which the module below carries instead. +data ncr_double_source: String = "module v2.ncr_double\n\nimport v2.std.logic { Bool }\nimport v2.ncr_home_a { NcrTwin }\nimport v2.ncr_home_b { NcrTwin }\n\nfn ncr_two(p: NcrTwin) -> Bool { p }\n" + +// THE MUTATION CONTROL, AUTHORED AS A MODULE RATHER THAN AS A DIFF. This module names `NcrTwin` +// without binding it, so the name is nowhere on its chain and the rule refuses it as UNBOUND. Under +// a reinstated global bare search the same reference is reachable -- the corpus has the spelling -- +// and this arm changes answer. That is the discriminator: it is red exactly when a spelling search +// is back, and no other edit moves it. +data ncr_unbound_source: String = "module v2.ncr_unbound\n\nimport v2.std.logic { Bool }\n\nfn ncr_reach(p: NcrTwin) -> Bool { p }\n" + +// THE WORKLOAD'S OWN REFUSAL, REBUILT AT FIXTURE SCALE. The native lane's run of +// //v2/test/parse/expression_bodied_fn_decl_parse:all (binary 7cb36d26) refused all seven tests at +// prepare on ONE reference: the bare variant `SubstrateInputsOnly` in the +// `data live_tree_disposition: LiveTreeDisposition = SubstrateInputsOnly` row every witness module +// declares, reported as resolve_ambiguous_on_global_bare and naming no binder, because the bare +// index had collapsed them. The three modules below are that shape: a home declaring a variant, an +// unrelated module spelling the same variant name -- which is what made the corpus-wide search +// ambiguous -- and a consumer that IMPORTS the home. The import is what the author wrote and what +// the corpus-wide search never consulted. +data ncr_disp_home_source: String = "module v2.ncr_disp_home + +type NcrDisp = NcrInputsOnly | NcrOther +" + +data ncr_disp_other_source: String = "module v2.ncr_disp_other + +type NcrOtherDisp = NcrInputsOnly | NcrElse +" + +data ncr_disp_user_source: String = "module v2.ncr_disp_user + +import v2.ncr_disp_home { NcrDisp, NcrInputsOnly } + +data ncr_disp: NcrDisp = NcrInputsOnly +" + +fn ncr_read(source: String, id: Symbol, unit: Symbol, path: String) -> DagSourceReadWitness { + DagSourceReadWitness { + source: Medium { carried: source, fidelity: Lossless }, + artifact: Artifact { kind: SourceFile, id: id, file_path: path }, + compilation_unit: unit, + source_root: DagTree + } +} + +fn ncr_disp_home_read() -> DagSourceReadWitness { + ncr_read(source: ncr_disp_home_source, id: ^ncr_disp_home_artifact, unit: ^ncr_disp_home_cu, path: "dag/ncr_disp_home.dag") +} + +fn ncr_disp_other_read() -> DagSourceReadWitness { + ncr_read(source: ncr_disp_other_source, id: ^ncr_disp_other_artifact, unit: ^ncr_disp_other_cu, path: "dag/ncr_disp_other.dag") +} + +fn ncr_disp_user_read() -> DagSourceReadWitness { + ncr_read(source: ncr_disp_user_source, id: ^ncr_disp_user_artifact, unit: ^ncr_disp_user_cu, path: "dag/ncr_disp_user.dag") +} + +fn ncr_home_a_read() -> DagSourceReadWitness { + ncr_read(source: ncr_home_a_source, id: ^ncr_home_a_artifact, unit: ^ncr_home_a_cu, path: "dag/ncr_home_a.dag") +} + +fn ncr_home_b_read() -> DagSourceReadWitness { + ncr_read(source: ncr_home_b_source, id: ^ncr_home_b_artifact, unit: ^ncr_home_b_cu, path: "dag/ncr_home_b.dag") +} + +fn ncr_importer_read() -> DagSourceReadWitness { + ncr_read(source: ncr_importer_source, id: ^ncr_importer_artifact, unit: ^ncr_importer_cu, path: "dag/ncr_importer.dag") +} + +fn ncr_double_read() -> DagSourceReadWitness { + ncr_read(source: ncr_double_source, id: ^ncr_double_artifact, unit: ^ncr_double_cu, path: "dag/ncr_double.dag") +} + +fn ncr_unbound_read() -> DagSourceReadWitness { + ncr_read(source: ncr_unbound_source, id: ^ncr_unbound_artifact, unit: ^ncr_unbound_cu, path: "dag/ncr_unbound.dag") +} + +// THE TWO ORDERINGS ARE THE SAME CORPUS, and that is the whole content of the file-order claim. +// A permutation of the ingest is the only difference between them; the importer's home is LAST in +// the reversed one, so a fill that resolved binding rows as it walked would find nothing for it. +data ncr_ingest: SourceRootIngest = Cons { + head: ncr_home_a_read(), + tail: Cons { + head: ncr_home_b_read(), + tail: Cons { + head: ncr_importer_read(), + tail: Cons { + head: ncr_double_read(), + tail: Cons { + head: ncr_unbound_read(), + tail: Cons { + head: ncr_disp_home_read(), + tail: Cons { + head: ncr_disp_other_read(), + tail: Cons { head: ncr_disp_user_read(), tail: Empty } + } + } + } + } + } + } +} + +data ncr_ingest_permuted: SourceRootIngest = Cons { + head: ncr_unbound_read(), + tail: Cons { + head: ncr_double_read(), + tail: Cons { + head: ncr_importer_read(), + tail: Cons { + head: ncr_home_b_read(), + tail: Cons { + head: ncr_home_a_read(), + tail: Cons { + head: ncr_disp_user_read(), + tail: Cons { + head: ncr_disp_other_read(), + tail: Cons { head: ncr_disp_home_read(), tail: Empty } + } + } + } + } + } + } +} + +fn ncr_outcome_in( + context: NativeTestContext, + index: Map, + ingest: SourceRootIngest, + module_name: String +) -> Optional { + fold_list( + xs: native_census_modules(ingest: ingest), + empty: optional_absent(), + cons: fn(acc, entry) { + if entry.module == module_name { + optional_present( + value: native_census_module_resolution( + context: context, + refusal_index: index, + entry: entry + ) + ) + } else { + acc + } + } + ) +} + +// A RESOLUTION IS REPORTED BY ITS CAUSE, NOT BY A BOOLEAN. An arm that only asked "did it refuse" +// would be green on the wrong refusal -- and the two refusals this fixture produces, an ambiguity +// and an unbound name, are exactly the pair that must not be confused: one says the rule saw two +// candidates, the other says it saw none. So each outcome is carried as a reason symbol, with the +// competing declarations the ambiguity named beside it. +type NcrVerdict + = NcrResolved + | NcrRefused { reason: Symbol, competing: FreeMonoid } + | NcrFileRefused + | NcrAbsent + +fn ncr_competing_declarations(d: NonEmptyDiagnostics) -> FreeMonoid { + fold_list( + xs: Cons { head: d.head, tail: d.tail }, + empty: Empty, + cons: fn(acc, diag) { + if diag.reason == ^resolve_ambiguous_competing_declaration { + match diag.at { + DeclarationLocus { declaration: path } => Cons { head: path, tail: acc } + Textual { file: _, extent: _ } => acc + NodeLocus { anchor: _ } => acc + SourcePortLocus { anchor: _, file: _, extent: _ } => acc + InvariantPortLocus { anchor: _, invariant: _ } => acc + } + } else { + acc + } + } + ) +} + +fn ncr_verdict_of(outcome: Optional) -> NcrVerdict { + match outcome { + Absent => NcrAbsent + Present { value: o } => + match o { + NativeCensusModuleResolved => NcrResolved + NativeCensusModuleFileRefused => NcrFileRefused + NativeCensusModuleResolveRefused { diagnostics: d } => + NcrRefused { + reason: diagnostics_fatal_reason(d: d), + competing: ncr_competing_declarations(d: d) + } + } + } +} + +// ONE NULLARY PRODUCER, FORCED ONCE, for the reason v2.test.claim.native_census.whole_tree_census_resolve +// states in full: the floor builds a fresh evaluation frame per claim, so a per-claim helper would +// re-enter the whole front end once per arm and be refused on cost -- a claim whose evaluation is +// dominated by ONE call whose result shape is all it inspects. This declaration is pure and nullary, +// so it is preparation-forceable, and each claim below reads a value. Both orderings are resolved +// here because the file-order claim compares them and every other claim reads the first. +type NcrOutcomes + = NcrContextRefused + | NcrOutcomesDecided { + importer: NcrVerdict + double: NcrVerdict + unbound: NcrVerdict + home_a: NcrVerdict + disp_user: NcrVerdict + importer_permuted: NcrVerdict + double_permuted: NcrVerdict + unbound_permuted: NcrVerdict + disp_user_permuted: NcrVerdict + } + +fn ncr_verdicts_for(ingest: SourceRootIngest, module_name: String) -> Optional { + match native_test_context_from_ingest(ingest: ingest) { + Rejected { diagnostics: _ } => optional_absent() + Accepted { value: context, diagnostics: _ } => + ncr_outcome_in( + context: context, + index: native_lane_file_refusal_index(refusals: context.file_refusals), + ingest: ingest, + module_name: module_name + ) + } +} + +fn ncr_outcomes() -> NcrOutcomes { + match native_test_context_from_ingest(ingest: ncr_ingest) { + Rejected { diagnostics: _ } => NcrContextRefused + Accepted { value: context, diagnostics: _ } => { + let index = native_lane_file_refusal_index(refusals: context.file_refusals) + match native_test_context_from_ingest(ingest: ncr_ingest_permuted) { + Rejected { diagnostics: _ } => NcrContextRefused + Accepted { value: permuted_context, diagnostics: _ } => { + let permuted_index = native_lane_file_refusal_index(refusals: permuted_context.file_refusals) + NcrOutcomesDecided { + importer: ncr_verdict_of(outcome: ncr_outcome_in(context: context, index: index, ingest: ncr_ingest, module_name: "v2.ncr_importer")), + double: ncr_verdict_of(outcome: ncr_outcome_in(context: context, index: index, ingest: ncr_ingest, module_name: "v2.ncr_double")), + unbound: ncr_verdict_of(outcome: ncr_outcome_in(context: context, index: index, ingest: ncr_ingest, module_name: "v2.ncr_unbound")), + disp_user: ncr_verdict_of(outcome: ncr_outcome_in(context: context, index: index, ingest: ncr_ingest, module_name: "v2.ncr_disp_user")), + disp_user_permuted: ncr_verdict_of(outcome: ncr_outcome_in(context: permuted_context, index: permuted_index, ingest: ncr_ingest_permuted, module_name: "v2.ncr_disp_user")), + home_a: ncr_verdict_of(outcome: ncr_outcome_in(context: context, index: index, ingest: ncr_ingest, module_name: "v2.ncr_home_a")), + importer_permuted: ncr_verdict_of(outcome: ncr_outcome_in(context: permuted_context, index: permuted_index, ingest: ncr_ingest_permuted, module_name: "v2.ncr_importer")), + double_permuted: ncr_verdict_of(outcome: ncr_outcome_in(context: permuted_context, index: permuted_index, ingest: ncr_ingest_permuted, module_name: "v2.ncr_double")), + unbound_permuted: ncr_verdict_of(outcome: ncr_outcome_in(context: permuted_context, index: permuted_index, ingest: ncr_ingest_permuted, module_name: "v2.ncr_unbound")) + } + } + } + } + } +} + +data ncr_outcomes_warm: NcrOutcomes = ncr_outcomes() + +fn ncr_verdict_is_resolved(v: NcrVerdict) -> Bool { + match v { + NcrResolved => true + NcrRefused { reason: _, competing: _ } => false + NcrFileRefused => false + NcrAbsent => false + } +} + +fn ncr_verdict_refuses_with(v: NcrVerdict, reason: Symbol) -> Bool { + match v { + NcrResolved => false + NcrRefused { reason: r, competing: _ } => r == reason + NcrFileRefused => false + NcrAbsent => false + } +} + +fn ncr_verdict_names(v: NcrVerdict, dotted: String) -> Bool { + match v { + NcrResolved => false + NcrRefused { reason: _, competing: competing } => + contains( + xs: competing, + item: qualified_name_from_dotted_string(dotted: dotted), + eq: fn(a, b) { a == b } + ) + NcrFileRefused => false + NcrAbsent => false + } +} + +fn ncr_verdict_competing_count(v: NcrVerdict) -> Int { + match v { + NcrResolved => 0 + NcrRefused { reason: _, competing: competing } => length(xs: competing) + NcrFileRefused => 0 + NcrAbsent => 0 + } +} + +fn ncr_verdict_same(left: NcrVerdict, right: NcrVerdict) -> Bool { + match left { + NcrResolved => ncr_verdict_is_resolved(v: right) + NcrFileRefused => match right { NcrFileRefused => true, NcrResolved => false, NcrRefused { reason: _, competing: _ } => false, NcrAbsent => false } + NcrAbsent => match right { NcrAbsent => true, NcrResolved => false, NcrRefused { reason: _, competing: _ } => false, NcrFileRefused => false } + NcrRefused { reason: r, competing: c } => + ncr_verdict_refuses_with(v: right, reason: r) + && ncr_verdict_competing_count(v: right) == length(xs: c) + && for_all(xs: c, predicate: fn(path) { + match right { + NcrRefused { reason: _, competing: rc } => + contains(xs: rc, item: path, eq: fn(a, b) { a == b }) + NcrResolved => false + NcrFileRefused => false + NcrAbsent => false + } + }) + } +} + +// A SAME-NAMED PAIR IN SIBLING SCOPES RESOLVES BY CONTAINMENT. `NcrTwin` is declared in both +// v2.ncr_home_a and v2.ncr_home_b; this module binds one of them and its reference resolves. The +// corpus-wide spelling is ambiguous and always was -- what makes the reference decidable is the +// binding at this module's own position, which is the rule's whole claim. +test fn a_bound_sibling_declaration_resolves_by_containment_holds() -> Bool { + match ncr_outcomes_warm { + NcrContextRefused => false + NcrOutcomesDecided { importer: importer, double: _, unbound: _, home_a: home_a, disp_user: _, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _ } => + ncr_verdict_is_resolved(v: importer) && ncr_verdict_is_resolved(v: home_a) + } +} + +// A GENUINE AMBIGUITY REFUSES AND NAMES BOTH BINDERS. Two import rows bind `NcrTwin` at one +// position, so the reference has two candidates and the rule may not pick. Either binder alone +// would be a pick wearing a refusal's clothes, so the count is asserted as well as the membership. +test fn a_genuine_ambiguity_refuses_naming_both_binders_holds() -> Bool { + match ncr_outcomes_warm { + NcrContextRefused => false + NcrOutcomesDecided { importer: _, double: double, unbound: _, home_a: _, disp_user: _, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _ } => + ncr_verdict_refuses_with(v: double, reason: ^resolve_reason_ambiguous_symbol) + && ncr_verdict_names(v: double, dotted: "v2.ncr_home_a.NcrTwin") + && ncr_verdict_names(v: double, dotted: "v2.ncr_home_b.NcrTwin") + && ncr_verdict_competing_count(v: double) == 2 + } +} + +// THE RED MUTATION CONTROL. A name the corpus declares twice and this module binds zero times is +// UNBOUND, not ambiguous and not resolved: a global spelling search never chooses a meaning, and +// here it has no meaning to offer. Reinstating a corpus-wide bare-name search moves this arm -- +// to resolved if the search picks, to ambiguous if it refuses over the spelling -- and nothing else +// in this file moves with it. +test fn an_unbound_name_the_corpus_spells_twice_stays_unbound_holds() -> Bool { + match ncr_outcomes_warm { + NcrContextRefused => false + NcrOutcomesDecided { importer: _, double: _, unbound: unbound, home_a: _, disp_user: _, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _ } => + ncr_verdict_refuses_with(v: unbound, reason: ^resolve_reason_unbound_symbol) + } +} + +// REORDERING FILES CHANGES NOTHING. The permuted ingest is the same five modules with the +// importer's home moved from first to last, which under a fill that resolved binding rows as it +// walked would leave the importer's binding unmade. Each of the three consumers is compared at the +// grain that would move -- the cause, and for the ambiguity the binder set -- rather than by a +// pass/fail flag that two different refusals would share. +test fn reordering_the_files_changes_no_verdict_holds() -> Bool { + match ncr_outcomes_warm { + NcrContextRefused => false + NcrOutcomesDecided { + importer: importer, + double: double, + unbound: unbound, + home_a: _, + disp_user: disp_user, + importer_permuted: importer_permuted, + double_permuted: double_permuted, + unbound_permuted: unbound_permuted, + disp_user_permuted: disp_user_permuted + } => + ncr_verdict_same(left: importer, right: importer_permuted) + && ncr_verdict_same(left: double, right: double_permuted) + && ncr_verdict_same(left: unbound, right: unbound_permuted) + && ncr_verdict_same(left: disp_user, right: disp_user_permuted) + } +} + + +// THE WORKLOAD'S REFUSAL, RESOLVED. This is the fixture-scale rebuild of the one reference that +// refused all seven tests of //v2/test/parse/expression_bodied_fn_decl_parse:all at prepare: a bare +// VARIANT name whose spelling a second, unrelated module also uses. The corpus-wide search saw two +// and could name neither; the rule sees the import the author wrote, which binds exactly one, and +// resolves. v2.ncr_disp_other stays in the ingest precisely so the spelling stays ambiguous -- drop +// it and this arm would pass under either mechanism and discriminate nothing. +test fn the_workload_bare_variant_reference_resolves_through_its_import_holds() -> Bool { + match ncr_outcomes_warm { + NcrContextRefused => false + NcrOutcomesDecided { importer: _, double: _, unbound: _, home_a: _, disp_user: disp_user, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _ } => + ncr_verdict_is_resolved(v: disp_user) + } +} diff --git a/src/v2/test/claim/symbol_index/containment_test.dag b/src/v2/test/claim/symbol_index/containment_test.dag index f2f21e1638e..ea8c643bc3e 100644 --- a/src/v2/test/claim/symbol_index/containment_test.dag +++ b/src/v2/test/claim/symbol_index/containment_test.dag @@ -342,7 +342,7 @@ data si_orphan_module_root_node: Node = si_module_root( ] ) -data si_multi_root_index: SymbolIndex = symbol_index_fill_module_roots( +data si_multi_root_index: SymbolIndex = symbol_index_fill_module_root_nodes( index: empty_symbol_index(), roots: Cons { head: si_module_root_node, From 877bb4855d6bfdfbc883f077041850469bd91fad Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Mon, 21 Sep 2026 21:05:13 +0000 Subject: [PATCH 02/17] The incumbent declaration counts as a claimant, and the test-code wall runs on the transmuted-import route Review 69708 on #12009 found the declare-and-import collision still silent, and it was right. `bound_declarings` was written only by symbol_index_bind_at; the pass that writes a module's OWN declarations (symbol_index_insert) records no claimant, because a containment path names one place in the tree and cannot be claimed twice. That left a position a DECLARATION holds indistinguishable from a position nothing holds -- `prior` empty either way -- so for a module that declares `N` and also imports `N`, pass B saw an empty position and overwrote the local declaration with the import. Silently. Which is the pick this machinery exists to refuse. symbol_index_claimants_at reads the incumbent instead of registering every declaration as it is written: an occupied position with no recorded claimant was written by pass A, where the binding path IS the declaring path, so that path is the incumbent's name. Registering all of them up front would double the index for the one position in a thousand that a second claimant ever reaches. New arm, and its mutation control run: declaring_and_importing_one_name_refuses_naming_both refuses naming `v2.ncr_shadow.NcrTwin` and `v2.ncr_home_a.NcrTwin`, count asserted, and dropping the incumbent seed reds it and nothing else. Also from the same review: ~20 admit_normalized_tree call sites. Fixed -- and the absence now has a name, `no_import_bindings()`, beside test_marker_channel_empty() and saying the same kind of thing: this input carries no import decls, rather than `Empty` leaving a reader to guess whether the caller dropped something. Two reds this surfaced in v2.test.claim.name_resolve.test_code_reference_wall, both green on main and both mine: the serving module reached its PEER's declarations with no binding at all, because the corpus-wide bare-name search found the spelling. Those claims were asserting the test-code wall on a route that no longer exists. The subject now carries the two binding rows `import wall.peer { peer_leaf, shared_fixture }` becomes, which STRENGTHENS the wall rather than accommodating it: a transmuted import is the one route that could let a test-marked declaration past by arriving under a local name, and the RED now runs on exactly that route. 16/16 green. Also moved the LexicalUnbound annotation above its declaration -- DESIGN section 4c admits module-item grain only, and the annotation parser refused it inside the body. Co-Authored-By: Claude Opus 5 (1M context) --- src/v2/compiler/03_resolve.dag | 32 +++++----- src/v2/compiler/normalized_tree.dag | 8 +++ src/v2/std/symbol_index.dag | 29 ++++++++- .../arrow_body_form_witness_test.dag | 4 +- ...transform_binary_infix_witness_helpers.dag | 4 +- .../wave1_gate1_normalize_add_helpers.dag | 6 +- .../claim/claim_pipeline/resolve_test.dag | 4 +- .../body_lowering_normalize_add_test.dag | 6 +- .../manual/resolve_compile_anchor_test.dag | 10 +-- .../admission_fail_closed_test.dag | 4 +- .../malformed_imported_root_test.dag | 4 +- .../one_member_cost_probe_test.dag | 4 +- .../test_code_reference_wall_test.dag | 38 +++++++++++- .../claim/normalized_tree_admission_test.dag | 12 ++-- .../parse/parse_binding_fidelity_test.dag | 4 +- .../resolve/namespace_candidate_rule_test.dag | 62 ++++++++++++++++--- 16 files changed, 176 insertions(+), 55 deletions(-) diff --git a/src/v2/compiler/03_resolve.dag b/src/v2/compiler/03_resolve.dag index d67248130c1..454516eecfc 100644 --- a/src/v2/compiler/03_resolve.dag +++ b/src/v2/compiler/03_resolve.dag @@ -530,6 +530,24 @@ type SymbolIndexAtomLookup | SymbolIndexAtomAmbiguous { class: AmbiguousLookupClass } | SymbolIndexAtomUnbound +// NOTHING FOLLOWS AN EXHAUSTED CHAIN, which is what the LexicalUnbound arm below now says by +// answering SymbolIndexAtomUnbound. That arm used to ask the corpus-wide bare-name index under +// NamespaceOnlyY, and that question has no answer it is entitled to give: a name the reference's +// own ancestor chain does not bind has no meaning AT THE REFERENCE, whatever else the corpus spells +// the same way. docs/plans/namespace-resolution-design.md section 13 deletes the tier by name -- +// "global_bare dies as a mechanism" -- and the reference model this corpus grounds lookup in agrees +// by ABSENCE: [basic.lookup.unqual] walks outward to the parent scope and, exhausted, the program +// is ill-formed; there is no clause that searches a whole program for a unique declaration. +// +// WHAT REPLACES THE TIER IS NOT A WEAKER ANSWER BUT A BINDING THE AUTHOR WROTE. An import is +// transmuted into a row at the importing position (v2.extdeps.languages.dag +// import_binding_rows_from_decl_node, bound by v2.compiler.symbol_index_fill), so the names a +// module actually uses are ON its chain and the lexical collector finds them there. Deleting the +// tier without that transmutation would not have made these references resolve; it would have made +// them unbound, which is why the two are one change. +// +// THE INDEX'S global_bare MAP SURVIVES, and only as what section 13 leaves it: a migration oracle +// for the divergence census. No resolution path reaches it. fn lookup_symbol_index_atom_identity( ctx: ResolveContext, name: Symbol @@ -546,20 +564,6 @@ fn lookup_symbol_index_atom_identity( } LexicalAmbiguous { candidates: candidates } => SymbolIndexAtomAmbiguous { class: AmbiguousOnLexicalChain { candidates: candidates } } - // NOTHING FOLLOWS AN EXHAUSTED CHAIN. This arm used to ask the corpus-wide bare-name index - // under NamespaceOnlyY, and that question has no answer it is entitled to give: a name the - // reference's own ancestor chain does not bind has no meaning AT THE REFERENCE, whatever else - // the corpus spells the same way. docs/plans/namespace-resolution-design.md section 13 deletes - // the tier by name -- "global_bare dies as a mechanism" -- and the reference model this corpus - // grounds lookup in agrees by ABSENCE: [basic.lookup.unqual] walks outward to the parent scope - // and, exhausted, the program is ill-formed; there is no clause that searches a whole program - // for a unique declaration. What replaces the tier is not a weaker answer but a BINDING the - // author wrote: an import is transmuted into a row at the importing position - // (v2.extdeps.languages.dag import_binding_rows_from_decl_node), so the names a module actually - // uses are ON its chain and the collector above finds them there. - // - // THE INDEX'S global_bare MAP SURVIVES, and only as what section 13 leaves it as: a migration - // oracle for the divergence census. No resolution path reaches it. LexicalUnbound => SymbolIndexAtomUnbound } } diff --git a/src/v2/compiler/normalized_tree.dag b/src/v2/compiler/normalized_tree.dag index 645c82eb319..230bd55d8ef 100644 --- a/src/v2/compiler/normalized_tree.dag +++ b/src/v2/compiler/normalized_tree.dag @@ -49,6 +49,14 @@ type NormalizedTree sole_constructor { // while holding retention evidence. Next-rung trigger: the retention disposition riding in the // carrier's own type rather than decided at the door. +// THE ABSENCE HAS A NAME, for the same reason test_marker_channel_empty does. A caller holding a +// hand-built root has no import decls to offer, and `Empty` at the call site says only "a list", +// leaving a reader to guess whether the caller dropped something. This says what is true: this +// input carries no import bindings. +fn no_import_bindings() -> FreeMonoid { + Empty +} + fn normalized_tree_retention_diagnostic(root: Node) -> Diagnostic { Diagnostic { reason: ^normalized_tree_reason_wrapper_retention_not_normalized, diff --git a/src/v2/std/symbol_index.dag b/src/v2/std/symbol_index.dag index 12ba35d99bc..0ce1db7f061 100644 --- a/src/v2/std/symbol_index.dag +++ b/src/v2/std/symbol_index.dag @@ -182,13 +182,40 @@ fn symbol_index_try_lexical_at( // docs/plans/namespace-resolution-design.md section 13 refuses. Re-binding the SAME declaration is // idempotent and contests nothing -- fill is run over roots whose bindings legitimately repeat, and // a position contested with itself would refuse every reference to a name bound exactly once. +// THE INCUMBENT COUNTS EVEN THOUGH IT NEVER REGISTERED. Pass A writes a module's own declarations +// through symbol_index_insert, which records no claimant -- it does not need one, because a +// containment path names one place in the tree and cannot be claimed twice. But that leaves a +// position a DECLARATION holds looking, to bind_at, exactly like a position nothing holds: `prior` +// is empty either way. A module that declares `N` and also imports `N` therefore had its own +// declaration overwritten by the import, silently, which is precisely the pick this machinery +// exists to refuse -- it shipped that way and a reviewer of gunbc#12009 found it. +// +// The repair reads the incumbent rather than registering every declaration as it is written: an +// occupied position with no recorded claimant was written by pass A, where the binding path IS the +// declaring path, so that path is the incumbent's name. Recording all of them up front would double +// the index for the one case in a thousand where a second claimant ever arrives. +fn symbol_index_claimants_at( + index: SymbolIndex, + binding_path: QualifiedName +) -> FreeMonoid { + let recorded = symbol_index_bound_declarings(index: index, binding_path: binding_path) + if is_empty(xs: recorded) { + match symbol_index_lookup(index: index, qualified_path: binding_path) { + Present { value: _ } => Cons { head: binding_path, tail: Empty } + Absent => Empty + } + } else { + recorded + } +} + fn symbol_index_bind_at( index: SymbolIndex, binding_path: QualifiedName, declaring_path: QualifiedName, resolved: Node ) -> SymbolIndex { - let prior = symbol_index_bound_declarings(index: index, binding_path: binding_path) + let prior = symbol_index_claimants_at(index: index, binding_path: binding_path) if contains(xs: prior, item: declaring_path, eq: fn(a, b) { a == b }) { index } else { diff --git a/src/v2/test/claim/body_lowering/arrow_body_form_witness_test.dag b/src/v2/test/claim/body_lowering/arrow_body_form_witness_test.dag index 4e1769e5bd8..93f0bb1b3c4 100644 --- a/src/v2/test/claim/body_lowering/arrow_body_form_witness_test.dag +++ b/src/v2/test/claim/body_lowering/arrow_body_form_witness_test.dag @@ -2,7 +2,7 @@ module v2.test.claim.body_lowering.arrow_body_form_witness import std.occurrence_identity { OccurrenceSynthetic } import v2.std.declaration_marker { test_marker_channel_empty } -import v2.compiler.normalized_tree { NormalizedTree, admit_normalized_tree } +import v2.compiler.normalized_tree { NormalizedTree, admit_normalized_tree, no_import_bindings} import v2.compiler.resolve { resolve } import v2.extdeps.languages.dag { dag_add_arrow_with_body_node, @@ -124,7 +124,7 @@ fn arrow_body_form_zero_arrow_fixture() -> Optional { // arm FAILS the witness rather than skipping it — an unadmittable fixture is a defect in // the fixture, not a reason to report green. fn abf_admitted_add_arrow_tree() -> Outcome { - admit_normalized_tree(test_markers: test_marker_channel_empty(), root: dag_add_arrow_with_body_node(), diagnostics: None) + admit_normalized_tree(import_bindings: no_import_bindings(), test_markers: test_marker_channel_empty(), root: dag_add_arrow_with_body_node(), diagnostics: None) } test fn arrow_body_form_eval_value_vertical_holds() -> Bool { diff --git a/src/v2/test/claim/body_lowering/infer_transform_binary_infix_witness_helpers.dag b/src/v2/test/claim/body_lowering/infer_transform_binary_infix_witness_helpers.dag index e75deaa29f6..a6f5e731e0e 100644 --- a/src/v2/test/claim/body_lowering/infer_transform_binary_infix_witness_helpers.dag +++ b/src/v2/test/claim/body_lowering/infer_transform_binary_infix_witness_helpers.dag @@ -7,7 +7,7 @@ import v2.compiler.infer { } import v2.compiler.resolve { resolve } import v2.std.declaration_marker { test_marker_channel_empty } -import v2.compiler.normalized_tree { admit_normalized_tree } +import v2.compiler.normalized_tree { admit_normalized_tree, no_import_bindings} import v2.extdeps.languages.dag { dag_int_literal_fixture_one, dag_int_literal_fixture_two, @@ -53,7 +53,7 @@ fn infer_transform_witness_resolved_add_arrow() -> Optional { match wave1_gate1_normalized_add_module() { Absent => Absent Present { value: tree } => - match admit_normalized_tree(test_markers: test_marker_channel_empty(), root: tree, diagnostics: None) { + match admit_normalized_tree(import_bindings: no_import_bindings(), test_markers: test_marker_channel_empty(), root: tree, diagnostics: None) { Rejected { diagnostics: _ } => Absent Accepted { value: normalized, diagnostics: _ } => match resolve(tree: normalized, lm: dag_language_model()) { diff --git a/src/v2/test/claim/body_lowering/wave1_gate1_normalize_add_helpers.dag b/src/v2/test/claim/body_lowering/wave1_gate1_normalize_add_helpers.dag index 234d752f4c4..80ebb753288 100644 --- a/src/v2/test/claim/body_lowering/wave1_gate1_normalize_add_helpers.dag +++ b/src/v2/test/claim/body_lowering/wave1_gate1_normalize_add_helpers.dag @@ -2,7 +2,7 @@ module v2.test.claim.body_lowering.wave1_gate1_normalize_add_helpers import v2.compiler.normalize { normalize } import v2.std.declaration_marker { test_marker_channel_empty } -import v2.compiler.normalized_tree { admit_normalized_tree } +import v2.compiler.normalized_tree { admit_normalized_tree, no_import_bindings} import v2.compiler.parse { parse_module } import v2.compiler.resolve { resolve } import v2.compiler.tokenize { tokenize } @@ -66,7 +66,7 @@ fn wave1_gate1_normalized_add_resolves_holds() -> Bool { match wave1_gate1_normalized_add_module() { Absent => false Present { value: tree } => - match admit_normalized_tree(test_markers: test_marker_channel_empty(), root: tree, diagnostics: None) { + match admit_normalized_tree(import_bindings: no_import_bindings(), test_markers: test_marker_channel_empty(), root: tree, diagnostics: None) { Rejected { diagnostics: _ } => false Accepted { value: normalized, diagnostics: _ } => match resolve(tree: normalized, lm: dag_language_model()) { @@ -101,7 +101,7 @@ fn wave1_gate1_resolved_add_arrow_transform_holds() -> Bool { match wave1_gate1_normalized_add_module() { Absent => false Present { value: tree } => - match admit_normalized_tree(test_markers: test_marker_channel_empty(), root: tree, diagnostics: None) { + match admit_normalized_tree(import_bindings: no_import_bindings(), test_markers: test_marker_channel_empty(), root: tree, diagnostics: None) { Rejected { diagnostics: _ } => false Accepted { value: normalized, diagnostics: _ } => match resolve(tree: normalized, lm: dag_language_model()) { diff --git a/src/v2/test/claim/claim_pipeline/resolve_test.dag b/src/v2/test/claim/claim_pipeline/resolve_test.dag index 3d8a46b157b..ce88ecaa850 100644 --- a/src/v2/test/claim/claim_pipeline/resolve_test.dag +++ b/src/v2/test/claim/claim_pipeline/resolve_test.dag @@ -3,7 +3,7 @@ module v2.test.claim_pipeline.resolve import std.occurrence_identity { OccurrenceSynthetic } import v2.compiler.normalize { normalize } import v2.std.declaration_marker { test_marker_channel_empty } -import v2.compiler.normalized_tree { admit_normalized_tree } +import v2.compiler.normalized_tree { admit_normalized_tree, no_import_bindings} import v2.compiler.resolve { resolve } import v2.extdeps.languages.dag { dag_language_model_surface_empty_prelude, @@ -68,7 +68,7 @@ data spine_resolve_reject_module_subject: Node = Node { // self-bound every Atom node anywhere in the tree -- including this // reference -- so it resolved without ever consulting canonical_symbols. test fn spine_resolve_rejects_noncanonical_root_atom() -> Bool { - match admit_normalized_tree(test_markers: test_marker_channel_empty(), root: spine_resolve_reject_module_subject, diagnostics: None) { + match admit_normalized_tree(import_bindings: no_import_bindings(), test_markers: test_marker_channel_empty(), root: spine_resolve_reject_module_subject, diagnostics: None) { Accepted { value: normalized, diagnostics: _ } => match resolve(tree: normalized, lm: dag_language_model_surface_empty_prelude()) { Accepted { value: _, diagnostics: _ } => false diff --git a/src/v2/test/claim/manual/body_lowering_normalize_add_test.dag b/src/v2/test/claim/manual/body_lowering_normalize_add_test.dag index 2329bbc9b3a..d65470f715e 100644 --- a/src/v2/test/claim/manual/body_lowering_normalize_add_test.dag +++ b/src/v2/test/claim/manual/body_lowering_normalize_add_test.dag @@ -9,7 +9,7 @@ import v2.compiler.body_lowering_fold { body_lower_unwrap_captured } import v2.std.declaration_marker { test_marker_channel_empty } -import v2.compiler.normalized_tree { NormalizedTree, admit_normalized_tree } +import v2.compiler.normalized_tree { NormalizedTree, admit_normalized_tree, no_import_bindings} import v2.compiler.normalize { normalize } import v2.compiler.resolve { resolve } import v2.compiler.parse { parse_module } @@ -360,7 +360,7 @@ fn body_lowering_tree_contains_atom(root: Node, wanted: Symbol) -> Bool { test fn body_lowering_fixture_resolves() -> Bool { let lm = dag_language_model() - match admit_normalized_tree(test_markers: test_marker_channel_empty(), root: dag_add_arrow_with_body_fixture, diagnostics: None) { + match admit_normalized_tree(import_bindings: no_import_bindings(), test_markers: test_marker_channel_empty(), root: dag_add_arrow_with_body_fixture, diagnostics: None) { Rejected { diagnostics: _ } => false Accepted { value: admitted, diagnostics: _ } => match resolve(tree: admitted, lm: lm) { @@ -730,7 +730,7 @@ test fn body_lowering_normalized_arrow_root_resolves() -> Bool { match body_lower_find_arrow_in_module(root: tree.root) { Absent => false Present { value: arrow } => - match admit_normalized_tree(test_markers: test_marker_channel_empty(), root: arrow, diagnostics: None) { + match admit_normalized_tree(import_bindings: no_import_bindings(), test_markers: test_marker_channel_empty(), root: arrow, diagnostics: None) { Rejected { diagnostics: _ } => false Accepted { value: admitted_arrow, diagnostics: _ } => match resolve(tree: admitted_arrow, lm: lm) { diff --git a/src/v2/test/claim/manual/resolve_compile_anchor_test.dag b/src/v2/test/claim/manual/resolve_compile_anchor_test.dag index 97181837627..025e4201ab1 100644 --- a/src/v2/test/claim/manual/resolve_compile_anchor_test.dag +++ b/src/v2/test/claim/manual/resolve_compile_anchor_test.dag @@ -2,7 +2,7 @@ module v2.test.manual.resolve_compile_anchor import std.occurrence_identity { OccurrenceSynthetic } import v2.std.declaration_marker { test_marker_channel_empty } -import v2.compiler.normalized_tree { NormalizedTree, admit_unmarked_normalized_roots, admit_normalized_tree } +import v2.compiler.normalized_tree { NormalizedTree, admit_unmarked_normalized_roots, admit_normalized_tree, no_import_bindings} import v2.compiler.name_resolve { Admission, Import, @@ -61,7 +61,7 @@ fn service_atom_conj_tree() -> Node { } fn anchor_resolve_service_atom_via_canonical_symbols() -> Outcome { - match admit_normalized_tree(test_markers: test_marker_channel_empty(), root: service_atom_conj_tree(), diagnostics: None) { + match admit_normalized_tree(import_bindings: no_import_bindings(), test_markers: test_marker_channel_empty(), root: service_atom_conj_tree(), diagnostics: None) { Rejected { diagnostics: r } => Rejected { diagnostics: r } Accepted { value: tree, diagnostics: _ } => resolve(tree: tree, lm: dag_language_model_void()) @@ -69,7 +69,7 @@ fn anchor_resolve_service_atom_via_canonical_symbols() -> Outcome } fn anchor_resolve_empty_prelude_service_atom() -> Outcome { - match admit_normalized_tree(test_markers: test_marker_channel_empty(), root: service_atom_conj_tree(), diagnostics: None) { + match admit_normalized_tree(import_bindings: no_import_bindings(), test_markers: test_marker_channel_empty(), root: service_atom_conj_tree(), diagnostics: None) { Rejected { diagnostics: r } => Rejected { diagnostics: r } Accepted { value: tree, diagnostics: _ } => resolve(tree: tree, lm: dag_language_model_surface_empty_prelude()) @@ -77,7 +77,7 @@ fn anchor_resolve_empty_prelude_service_atom() -> Outcome { } fn anchor_resolve_multi_edge_conj_via_canonical_symbols() -> Outcome { - match admit_normalized_tree( + match admit_normalized_tree(import_bindings: no_import_bindings(), test_markers: test_marker_channel_empty(), root: Node { kind: TypeNode { connective: Conj }, @@ -113,7 +113,7 @@ fn anchor_resolve_multi_edge_conj_via_canonical_symbols() -> Outcome Outcome { - match admit_normalized_tree( + match admit_normalized_tree(import_bindings: no_import_bindings(), test_markers: test_marker_channel_empty(), root: Node { kind: TypeNode { connective: Conj }, diff --git a/src/v2/test/claim/name_resolve/admission_fail_closed_test.dag b/src/v2/test/claim/name_resolve/admission_fail_closed_test.dag index 7eaf3d8390a..d3db6f09ee7 100644 --- a/src/v2/test/claim/name_resolve/admission_fail_closed_test.dag +++ b/src/v2/test/claim/name_resolve/admission_fail_closed_test.dag @@ -19,7 +19,7 @@ import v2.compiler.name_resolve { validate_module_roots } import v2.std.declaration_marker { test_marker_channel_empty } -import v2.compiler.normalized_tree { NormalizedTree, admit_normalized_tree } +import v2.compiler.normalized_tree { NormalizedTree, admit_normalized_tree, no_import_bindings} import v2.std.cross_tree.resolution { source_root_index_empty } import v2.extdeps.languages.dag { dag_grammar_atom, @@ -128,7 +128,7 @@ fn fc_with_roots( } fn fc_admitted(n: Node) -> Outcome { - admit_normalized_tree(test_markers: test_marker_channel_empty(), root: n, diagnostics: None) + admit_normalized_tree(import_bindings: no_import_bindings(), test_markers: test_marker_channel_empty(), root: n, diagnostics: None) } fn fc_duplicate_roots() -> Outcome> { diff --git a/src/v2/test/claim/name_resolve/malformed_imported_root_test.dag b/src/v2/test/claim/name_resolve/malformed_imported_root_test.dag index 81714c0706c..26c43b307f2 100644 --- a/src/v2/test/claim/name_resolve/malformed_imported_root_test.dag +++ b/src/v2/test/claim/name_resolve/malformed_imported_root_test.dag @@ -10,7 +10,7 @@ import v2.compiler.name_resolve { admit_import_entry } import v2.std.declaration_marker { test_marker_channel_empty } -import v2.compiler.normalized_tree { NormalizedTree, admit_normalized_tree } +import v2.compiler.normalized_tree { NormalizedTree, admit_normalized_tree, no_import_bindings} import v2.std.cross_tree.resolution { source_root_index_empty } import v2.std.diagnostic { Accepted, None, Outcome, Rejected, bind_outcome, outcome_accepted } import v2.extdeps.languages.dag { @@ -64,7 +64,7 @@ fn malformed_entry_name() -> QualifiedName { } fn roots_with_malformed_entry() -> Outcome> { - bind_outcome(o: admit_normalized_tree(test_markers: test_marker_channel_empty(), root: malformed_root_node(), diagnostics: None), f: fn(a) { + bind_outcome(o: admit_normalized_tree(import_bindings: no_import_bindings(), test_markers: test_marker_channel_empty(), root: malformed_root_node(), diagnostics: None), f: fn(a) { outcome_accepted(value: Cons { head: a, tail: Empty }) }) } diff --git a/src/v2/test/claim/name_resolve/one_member_cost_probe_test.dag b/src/v2/test/claim/name_resolve/one_member_cost_probe_test.dag index daf6fd0debd..b034f943dce 100644 --- a/src/v2/test/claim/name_resolve/one_member_cost_probe_test.dag +++ b/src/v2/test/claim/name_resolve/one_member_cost_probe_test.dag @@ -6,7 +6,7 @@ import v2.compiler.name_resolve { ResolutionSubject, resolve_with_admission } -import v2.compiler.normalized_tree { NormalizedTree, admit_normalized_tree } +import v2.compiler.normalized_tree { NormalizedTree, admit_normalized_tree, no_import_bindings} import v2.std.declaration_marker { test_marker_channel_empty } import v2.compiler.parse { parse_module } import v2.compiler.resolve { ResolvedTree } @@ -72,7 +72,7 @@ fn probe_one_member_qn() -> QualifiedName { fn probe_roots_for_export(export_sym: Symbol) -> Outcome> { bind_outcome( - o: admit_normalized_tree( + o: admit_normalized_tree(import_bindings: no_import_bindings(), test_markers: test_marker_channel_empty(), root: probe_one_member_root_export(export_sym: export_sym), diagnostics: None diff --git a/src/v2/test/claim/name_resolve/test_code_reference_wall_test.dag b/src/v2/test/claim/name_resolve/test_code_reference_wall_test.dag index 2d1464c8878..1a2e285d48e 100644 --- a/src/v2/test/claim/name_resolve/test_code_reference_wall_test.dag +++ b/src/v2/test/claim/name_resolve/test_code_reference_wall_test.dag @@ -107,7 +107,41 @@ fn wall_channel(marked: List) -> TestMarkerChannel { } fn wall_tree(root: Node, marked: List) -> Outcome { - admit_normalized_tree(root: root, test_markers: wall_channel(marked: marked), diagnostics: None) + admit_normalized_tree(import_bindings: no_import_bindings(), root: root, test_markers: wall_channel(marked: marked), diagnostics: None) +} + +// THE PEER'S NAMES ARE BOUND, BECAUSE UNDER THE CANDIDATE RULE THAT IS THE ONLY WAY TO REACH THEM. +// These two rows are what `import wall.peer { peer_leaf, shared_fixture }` becomes +// (v2.extdeps.languages.dag import_binding_rows_from_decl_node), supplied here because these roots +// are built rather than parsed. Before gunbc#12009 the subject reached the peer with no binding at +// all -- the corpus-wide bare-name search found the spelling -- so these claims were, without +// saying so, asserting the wall on a route that no longer exists. +// +// THIS STRENGTHENS THE WALL RATHER THAN ACCOMMODATING IT. The refusal must fire on the path the +// reference BOUND TO, and a transmuted import binds at the importing module's position while its +// declaration stays in the peer. So this is the one route that could have let a test-marked +// declaration in past the wall by arriving under a local name, and the RED below now runs on +// exactly that route. +fn wall_peer_import_bindings() -> FreeMonoid { + [ + AliasBindingRow { + binding: ^peer_leaf, + target: Cons { head: ^wall, tail: Cons { head: ^peer, tail: Cons { head: ^peer_leaf, tail: Empty } } } + }, + AliasBindingRow { + binding: ^shared_fixture, + target: Cons { head: ^wall, tail: Cons { head: ^peer, tail: Cons { head: ^shared_fixture, tail: Empty } } } + } + ] +} + +fn wall_tree_importing_peer(root: Node, marked: List) -> Outcome { + admit_normalized_tree( + import_bindings: wall_peer_import_bindings(), + root: root, + test_markers: wall_channel(marked: marked), + diagnostics: None + ) } fn wall_verdict(outcome: Outcome) -> String { @@ -199,7 +233,7 @@ fn peer_serving_module() -> Outcome { // A one-member serving module wall.serving whose only fn's body is `body`. fn serving_module_with_body(body: Node) -> Outcome { - wall_tree(root: wall_module_root(leaf_segment: ^serving, members: [wall_member(name: ^uses_peer, body: body)]), marked: []) + wall_tree_importing_peer(root: wall_module_root(leaf_segment: ^serving, members: [wall_member(name: ^uses_peer, body: body)]), marked: []) } // INHABITANCE, THE ROUTE THE SUPPLIED ROOTS STAND ON: the real front end, on the source text the diff --git a/src/v2/test/claim/normalized_tree_admission_test.dag b/src/v2/test/claim/normalized_tree_admission_test.dag index dfeb62841f4..907ae3a5eb3 100644 --- a/src/v2/test/claim/normalized_tree_admission_test.dag +++ b/src/v2/test/claim/normalized_tree_admission_test.dag @@ -3,7 +3,7 @@ module v2.test.claim.normalized_tree_admission import v2.compiler.body_lowering_fold { body_lower_wrapper_retained_shell } import std.algebra { Cons, Empty, FreeMonoid } import v2.std.declaration_marker { test_marker_channel_empty } -import v2.compiler.normalized_tree { admit_unmarked_normalized_roots, admit_normalized_tree } +import v2.compiler.normalized_tree { admit_unmarked_normalized_roots, admit_normalized_tree, no_import_bindings} import v2.std.diagnostic { Accepted, Diagnostic, @@ -56,7 +56,7 @@ fn is_rejected(o: Outcome) -> Bool { } test fn a_retained_root_is_refused_at_the_carrier_door() -> Bool { - is_rejected(o: admit_normalized_tree( + is_rejected(o: admit_normalized_tree(import_bindings: no_import_bindings(), test_markers: test_marker_channel_empty(), root: probe_shell(), diagnostics: Some { @@ -66,7 +66,7 @@ test fn a_retained_root_is_refused_at_the_carrier_door() -> Bool { } test fn retention_is_found_past_the_diagnostic_head_RED() -> Bool { - is_rejected(o: admit_normalized_tree( + is_rejected(o: admit_normalized_tree(import_bindings: no_import_bindings(), test_markers: test_marker_channel_empty(), root: probe_shell(), diagnostics: Some { @@ -79,11 +79,11 @@ test fn retention_is_found_past_the_diagnostic_head_RED() -> Bool { } test fn a_clean_root_is_admitted_positive_control() -> Bool { - (is_rejected(o: admit_normalized_tree(test_markers: test_marker_channel_empty(), root: probe_shell(), diagnostics: None)) == false) + (is_rejected(o: admit_normalized_tree(import_bindings: no_import_bindings(), test_markers: test_marker_channel_empty(), root: probe_shell(), diagnostics: None)) == false) } test fn an_unrelated_diagnostic_does_not_refuse_RED() -> Bool { - (is_rejected(o: admit_normalized_tree( + (is_rejected(o: admit_normalized_tree(import_bindings: no_import_bindings(), test_markers: test_marker_channel_empty(), root: probe_shell(), diagnostics: Some { @@ -94,7 +94,7 @@ test fn an_unrelated_diagnostic_does_not_refuse_RED() -> Bool { test fn the_production_retention_producer_is_refused_by_the_door() -> Bool { match body_lower_wrapper_retained_shell(shell: probe_shell()) { - Accepted { value: v, diagnostics: d } => is_rejected(o: admit_normalized_tree(test_markers: test_marker_channel_empty(), root: v, diagnostics: d)) + Accepted { value: v, diagnostics: d } => is_rejected(o: admit_normalized_tree(import_bindings: no_import_bindings(), test_markers: test_marker_channel_empty(), root: v, diagnostics: d)) Rejected { diagnostics: _ } => false } } diff --git a/src/v2/test/claim/parse/parse_binding_fidelity_test.dag b/src/v2/test/claim/parse/parse_binding_fidelity_test.dag index 264f6f60924..4876fff053c 100644 --- a/src/v2/test/claim/parse/parse_binding_fidelity_test.dag +++ b/src/v2/test/claim/parse/parse_binding_fidelity_test.dag @@ -2,7 +2,7 @@ module v2.test.parse.parse_binding_fidelity import std.occurrence_identity { OccurrenceSynthetic } import v2.std.declaration_marker { test_marker_channel_empty } -import v2.compiler.normalized_tree { NormalizedTree, admit_unmarked_normalized_roots, admit_normalized_tree } +import v2.compiler.normalized_tree { NormalizedTree, admit_unmarked_normalized_roots, admit_normalized_tree, no_import_bindings} import v2.compiler.name_resolve { Admission, Import, @@ -201,7 +201,7 @@ fn pbf_unbound_atom_tree() -> Node { } fn pbf_resolve_unbound_symbol_rejects() -> Bool { - match admit_normalized_tree(test_markers: test_marker_channel_empty(), root: pbf_unbound_atom_tree(), diagnostics: None) { + match admit_normalized_tree(import_bindings: no_import_bindings(), test_markers: test_marker_channel_empty(), root: pbf_unbound_atom_tree(), diagnostics: None) { Rejected { diagnostics: _ } => false Accepted { value: admitted_tree, diagnostics: _ } => match resolve( diff --git a/src/v2/test/claim/resolve/namespace_candidate_rule_test.dag b/src/v2/test/claim/resolve/namespace_candidate_rule_test.dag index 224c4565c7a..02d80b06694 100644 --- a/src/v2/test/claim/resolve/namespace_candidate_rule_test.dag +++ b/src/v2/test/claim/resolve/namespace_candidate_rule_test.dag @@ -111,6 +111,22 @@ import v2.ncr_disp_home { NcrDisp, NcrInputsOnly } data ncr_disp: NcrDisp = NcrInputsOnly " +// DECLARING A NAME AND IMPORTING IT IS AN AMBIGUITY AT DISTANCE ZERO. Both claimants land on ONE +// binding position -- this module's own -- rather than on two positions of the chain, so it is the +// case a per-position map has exactly one slot for and would otherwise resolve by whichever writer +// ran last. It shipped doing exactly that (a reviewer of gunbc#12009 found it: the pass that writes +// a module's own declarations records no claimant, so the import saw an empty position and took +// it), and this arm is why it cannot ship that way again. +data ncr_shadow_source: String = "module v2.ncr_shadow + +import v2.std.logic { Bool } +import v2.ncr_home_a { NcrTwin } + +type NcrTwin = Bool + +fn ncr_shadow_use(p: NcrTwin) -> Bool { p } +" + fn ncr_read(source: String, id: Symbol, unit: Symbol, path: String) -> DagSourceReadWitness { DagSourceReadWitness { source: Medium { carried: source, fidelity: Lossless }, @@ -120,6 +136,10 @@ fn ncr_read(source: String, id: Symbol, unit: Symbol, path: String) -> DagSource } } +fn ncr_shadow_read() -> DagSourceReadWitness { + ncr_read(source: ncr_shadow_source, id: ^ncr_shadow_artifact, unit: ^ncr_shadow_cu, path: "dag/ncr_shadow.dag") +} + fn ncr_disp_home_read() -> DagSourceReadWitness { ncr_read(source: ncr_disp_home_source, id: ^ncr_disp_home_artifact, unit: ^ncr_disp_home_cu, path: "dag/ncr_disp_home.dag") } @@ -169,7 +189,10 @@ data ncr_ingest: SourceRootIngest = Cons { head: ncr_disp_home_read(), tail: Cons { head: ncr_disp_other_read(), - tail: Cons { head: ncr_disp_user_read(), tail: Empty } + tail: Cons { + head: ncr_disp_user_read(), + tail: Cons { head: ncr_shadow_read(), tail: Empty } + } } } } @@ -192,7 +215,10 @@ data ncr_ingest_permuted: SourceRootIngest = Cons { head: ncr_disp_user_read(), tail: Cons { head: ncr_disp_other_read(), - tail: Cons { head: ncr_disp_home_read(), tail: Empty } + tail: Cons { + head: ncr_disp_home_read(), + tail: Cons { head: ncr_shadow_read(), tail: Empty } + } } } } @@ -287,10 +313,12 @@ type NcrOutcomes unbound: NcrVerdict home_a: NcrVerdict disp_user: NcrVerdict + shadow: NcrVerdict importer_permuted: NcrVerdict double_permuted: NcrVerdict unbound_permuted: NcrVerdict disp_user_permuted: NcrVerdict + shadow_permuted: NcrVerdict } fn ncr_verdicts_for(ingest: SourceRootIngest, module_name: String) -> Optional { @@ -319,6 +347,8 @@ fn ncr_outcomes() -> NcrOutcomes { importer: ncr_verdict_of(outcome: ncr_outcome_in(context: context, index: index, ingest: ncr_ingest, module_name: "v2.ncr_importer")), double: ncr_verdict_of(outcome: ncr_outcome_in(context: context, index: index, ingest: ncr_ingest, module_name: "v2.ncr_double")), unbound: ncr_verdict_of(outcome: ncr_outcome_in(context: context, index: index, ingest: ncr_ingest, module_name: "v2.ncr_unbound")), + shadow: ncr_verdict_of(outcome: ncr_outcome_in(context: context, index: index, ingest: ncr_ingest, module_name: "v2.ncr_shadow")), + shadow_permuted: ncr_verdict_of(outcome: ncr_outcome_in(context: permuted_context, index: permuted_index, ingest: ncr_ingest_permuted, module_name: "v2.ncr_shadow")), disp_user: ncr_verdict_of(outcome: ncr_outcome_in(context: context, index: index, ingest: ncr_ingest, module_name: "v2.ncr_disp_user")), disp_user_permuted: ncr_verdict_of(outcome: ncr_outcome_in(context: permuted_context, index: permuted_index, ingest: ncr_ingest_permuted, module_name: "v2.ncr_disp_user")), home_a: ncr_verdict_of(outcome: ncr_outcome_in(context: context, index: index, ingest: ncr_ingest, module_name: "v2.ncr_home_a")), @@ -402,7 +432,7 @@ fn ncr_verdict_same(left: NcrVerdict, right: NcrVerdict) -> Bool { test fn a_bound_sibling_declaration_resolves_by_containment_holds() -> Bool { match ncr_outcomes_warm { NcrContextRefused => false - NcrOutcomesDecided { importer: importer, double: _, unbound: _, home_a: home_a, disp_user: _, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _ } => + NcrOutcomesDecided { importer: importer, double: _, unbound: _, home_a: home_a, disp_user: _, shadow: _, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _ } => ncr_verdict_is_resolved(v: importer) && ncr_verdict_is_resolved(v: home_a) } } @@ -413,7 +443,7 @@ test fn a_bound_sibling_declaration_resolves_by_containment_holds() -> Bool { test fn a_genuine_ambiguity_refuses_naming_both_binders_holds() -> Bool { match ncr_outcomes_warm { NcrContextRefused => false - NcrOutcomesDecided { importer: _, double: double, unbound: _, home_a: _, disp_user: _, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _ } => + NcrOutcomesDecided { importer: _, double: double, unbound: _, home_a: _, disp_user: _, shadow: _, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _ } => ncr_verdict_refuses_with(v: double, reason: ^resolve_reason_ambiguous_symbol) && ncr_verdict_names(v: double, dotted: "v2.ncr_home_a.NcrTwin") && ncr_verdict_names(v: double, dotted: "v2.ncr_home_b.NcrTwin") @@ -429,7 +459,7 @@ test fn a_genuine_ambiguity_refuses_naming_both_binders_holds() -> Bool { test fn an_unbound_name_the_corpus_spells_twice_stays_unbound_holds() -> Bool { match ncr_outcomes_warm { NcrContextRefused => false - NcrOutcomesDecided { importer: _, double: _, unbound: unbound, home_a: _, disp_user: _, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _ } => + NcrOutcomesDecided { importer: _, double: _, unbound: unbound, home_a: _, disp_user: _, shadow: _, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _ } => ncr_verdict_refuses_with(v: unbound, reason: ^resolve_reason_unbound_symbol) } } @@ -448,15 +478,18 @@ test fn reordering_the_files_changes_no_verdict_holds() -> Bool { unbound: unbound, home_a: _, disp_user: disp_user, + shadow: shadow, importer_permuted: importer_permuted, double_permuted: double_permuted, unbound_permuted: unbound_permuted, - disp_user_permuted: disp_user_permuted + disp_user_permuted: disp_user_permuted, + shadow_permuted: shadow_permuted } => ncr_verdict_same(left: importer, right: importer_permuted) && ncr_verdict_same(left: double, right: double_permuted) && ncr_verdict_same(left: unbound, right: unbound_permuted) && ncr_verdict_same(left: disp_user, right: disp_user_permuted) + && ncr_verdict_same(left: shadow, right: shadow_permuted) } } @@ -470,7 +503,22 @@ test fn reordering_the_files_changes_no_verdict_holds() -> Bool { test fn the_workload_bare_variant_reference_resolves_through_its_import_holds() -> Bool { match ncr_outcomes_warm { NcrContextRefused => false - NcrOutcomesDecided { importer: _, double: _, unbound: _, home_a: _, disp_user: disp_user, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _ } => + NcrOutcomesDecided { importer: _, double: _, unbound: _, home_a: _, disp_user: disp_user, shadow: _, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _ } => ncr_verdict_is_resolved(v: disp_user) } } + +// A MODULE THAT DECLARES A NAME AND ALSO IMPORTS IT REFUSES, NAMING BOTH. Two claimants, one +// binding position. Either name alone would mean the index had silently kept one writer -- the +// import if it overwrote, the declaration if the import were merely dropped -- so both are +// asserted, and the count with them. +test fn declaring_and_importing_one_name_refuses_naming_both_holds() -> Bool { + match ncr_outcomes_warm { + NcrContextRefused => false + NcrOutcomesDecided { importer: _, double: _, unbound: _, home_a: _, disp_user: _, shadow: shadow, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _ } => + ncr_verdict_refuses_with(v: shadow, reason: ^resolve_reason_ambiguous_symbol) + && ncr_verdict_names(v: shadow, dotted: "v2.ncr_shadow.NcrTwin") + && ncr_verdict_names(v: shadow, dotted: "v2.ncr_home_a.NcrTwin") + && ncr_verdict_competing_count(v: shadow) == 2 + } +} From 4d086a8a90451db4f617449dbfb8f7e52cda0493 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Mon, 21 Sep 2026 21:50:45 +0000 Subject: [PATCH 03/17] Binding rows bind to a fixed point, because a re-export chain is a row whose target is another row Review 69735 on #12009 found the order-independence claim larger than its evidence, and it was right. Splitting declarations from bindings orders a row whose target is a DECLARATION -- pass A writes every one in the corpus before any row is tried. It does NOT order a row whose target is another row's BINDING. Collecting rows per root and binding them as the fold walked still gave each row exactly one attempt, in root order, so a re-export chain resolved in one root order and silently not in the other, and the loser bound nothing: the same quiet absence the two-pass split was supposed to remove, surviving one level up. Not hypothetical in this corpus, and live in this PR's own diff: v2.std.collection imports `Absent`/`Present` from v2.std.optional and declares neither, and v2.compiler.normalize -- a file this PR edits -- imports `Absent`/`Present` from v2.std.collection. That row's target exists only after collection's own row has bound. So rows are now gathered from EVERY root as values before any is applied, and retried until no further row can bind. A row whose target is not yet present is DEFERRED rather than dropped. It terminates because the recursion is entered only when the deferred list got strictly shorter, so the measure decreases; rows still deferred at the end name targets nothing declares or binds, they bind nothing, and the reference that wanted them refuses at the REFERENCE site, which is where section 13 puts that refusal. Evidence, because the finding was precisely that the comment claimed a rung the executed evidence did not establish (DESIGN section 4b(1)). The permutation control's fixtures were all single-level -- ncr_home_a DECLARES NcrTwin -- so nothing discriminated the chained case. Added v2.ncr_relay (imports NcrTwin, declares nothing) and v2.ncr_chain (imports it from the relay), placed so the permutation SWAPS them: relay before chain in one ingest, after it in the other. Two arms, because neither alone is enough. a_re_export_chain_resolves_through_its_relay says the chain resolves at all -- "unchanged under permutation" would be satisfied by two matching UNBOUND refusals. reordering_the_files_changes_no_verdict says the two orders agree. Mutation control run: collapsing the fixed point to a single round reds reordering_the_files_changes_no_verdict and nothing else. The chain arm stays green under that mutation, because it reads the ingest whose order happens to work -- which is the whole reason both arms exist. Sweep: 18 resolve-adjacent suites, zero failures. Co-Authored-By: Claude Opus 5 (1M context) --- src/v2/compiler/symbol_index_fill.dag | 210 +++++++++++++----- src/v2/std/namespace_alias.dag | 10 + .../resolve/namespace_candidate_rule_test.dag | 71 +++++- 3 files changed, 225 insertions(+), 66 deletions(-) diff --git a/src/v2/compiler/symbol_index_fill.dag b/src/v2/compiler/symbol_index_fill.dag index d592f9c5218..2d9e43d5f74 100644 --- a/src/v2/compiler/symbol_index_fill.dag +++ b/src/v2/compiler/symbol_index_fill.dag @@ -3,7 +3,7 @@ module v2.compiler.symbol_index_fill import std.decl_ref { DeclarationRef, WholeDeclaration } import std.disposition { Disposition, Scaffold, SingleAuthority } import std.algebra { Empty, FreeMonoid } -import v2.std.algebra { fold_list } +import v2.std.algebra { fold_list, is_empty, length, list_append, list_snoc_item } import v2.std.collection { Map, empty_map, @@ -36,7 +36,7 @@ import v2.std.symbol_index { symbol_index_insert, symbol_index_lookup } -import v2.std.namespace_alias { AliasBindingRow, ModuleBindingSource } +import v2.std.namespace_alias { AliasBindingRow, ModuleBindingSource, PendingBinding } import v2.extdeps.languages.dag { alias_binding_row_from_decl_node, import_binding_rows_from_decl_node, @@ -266,30 +266,123 @@ fn symbol_index_fill_import_binding( } } -fn symbol_index_fill_alias_bindings( - index: SymbolIndex, +// EVERY ROW THIS MODULE BINDS, AS VALUES, BEFORE ANY OF THEM IS APPLIED. Collecting is separated +// from binding because a row cannot be bound until its target is present, and which rows are +// bindable changes as other rows bind (see symbol_index_fill_bindings_to_fixed_point). A fold that +// bound as it walked could only ever make one attempt per row. +fn symbol_index_alias_rows_of( module_qn: QualifiedName, root: Node -) -> SymbolIndex { +) -> FreeMonoid { fold( node_subtree_nodes(root: root), - init: index, + init: Empty, f: fn(acc, n) { - symbol_index_fill_import_binding( - index: symbol_index_fill_alias_binding( - index: acc, - module_qn: module_qn, - root: root, - decl: n - ), - module_qn: module_qn, - root: root, - decl: n + match alias_binding_row_from_decl_node(decl: n, module_root: root) { + Rejected { diagnostics: _ } => acc + Accepted { value: row, diagnostics: _ } => + list_snoc_item(xs: acc, item: PendingBinding { module_qn: module_qn, row: row }) + } + } + ) +} + +fn symbol_index_pending_of_source(tree: ModuleBindingSource) -> FreeMonoid { + match qualified_name_from_module_node(root: tree.root) { + Rejected { diagnostics: _ } => Empty + Accepted { value: module_qn, diagnostics: _ } => + fold_list( + xs: tree.import_bindings, + empty: symbol_index_alias_rows_of(module_qn: module_qn, root: tree.root), + cons: fn(acc, row) { + list_snoc_item(xs: acc, item: PendingBinding { module_qn: module_qn, row: row }) + } ) + } +} + +// ONE ATTEMPT AT EVERY PENDING ROW, KEEPING THE ONES THAT COULD NOT BIND. A row whose target is not +// in the index yet is DEFERRED, not dropped: its target may be another row's binding, and that row +// may not have been reached yet. +type BindRound { + index: SymbolIndex + deferred: FreeMonoid +} + +fn symbol_index_bind_pending_round( + index: SymbolIndex, + pending: FreeMonoid +) -> BindRound { + fold_list( + xs: pending, + empty: BindRound { index: index, deferred: Empty }, + cons: fn(acc, p) { + match symbol_index_lookup(index: acc.index, qualified_path: p.row.target) { + Present { value: resolved } => + BindRound { + index: symbol_index_bind_at( + index: acc.index, + binding_path: qualified_name_snoc(qn: p.module_qn, segment: p.row.binding), + declaring_path: p.row.target, + resolved: resolved + ), + deferred: acc.deferred + } + Absent => + BindRound { + index: acc.index, + deferred: list_snoc_item(xs: acc.deferred, item: p) + } + } } ) } +// THE FIXED POINT IS WHAT MAKES ORDER-INDEPENDENCE TRUE ONE LEVEL UP, and one level up is where the +// first version of this was wrong (review 69735 on gunbc#12009). Splitting declarations from +// bindings removes the order dependence only for a row whose target is a DECLARATION -- pass A +// writes every one of those before any row is tried. It does not remove it for a RE-EXPORT CHAIN, +// where a row's target is another row's binding: `v2.std.collection` imports `Absent` from +// `v2.std.optional` and declares it nowhere, and `v2.compiler.normalize` imports `Absent` from +// `v2.std.collection`, so normalize's row can only bind after collection's has. A single pass over +// the roots resolves that pair in one root order and silently not in the other, and the loser binds +// NOTHING -- the quiet failure again, surfacing far away as an unbound reference. +// +// Retrying the deferred rows removes it for chains of any depth without needing the dependency +// order computed: each round binds every row whose target has become available. It TERMINATES +// because each recursion is entered only when the deferred list got strictly shorter, so the +// measure decreases and the last round is either empty or made no progress. Rows still deferred at +// the end name targets nothing in this corpus declares OR binds; they bind nothing, and the +// reference that wanted them refuses, located, at the reference site -- which is where +// docs/plans/namespace-resolution-design.md section 13 puts that refusal. +fn symbol_index_fill_bindings_to_fixed_point( + index: SymbolIndex, + pending: FreeMonoid +) -> SymbolIndex { + let round = symbol_index_bind_pending_round(index: index, pending: pending) + if is_empty(xs: round.deferred) { + round.index + } else if length(xs: round.deferred) == length(xs: pending) { + round.index + } else { + symbol_index_fill_bindings_to_fixed_point( + index: round.index, + pending: round.deferred + ) + } +} + +fn symbol_index_fill_alias_bindings( + index: SymbolIndex, + module_qn: QualifiedName, + root: Node +) -> SymbolIndex { + symbol_index_fill_bindings_to_fixed_point( + index: index, + pending: symbol_index_alias_rows_of(module_qn: module_qn, root: root) + ) +} + fn symbol_index_fill_unique_variant_aliases( index: SymbolIndex, module_qn: QualifiedName, @@ -348,21 +441,10 @@ fn symbol_index_fill_module_declarations(index: SymbolIndex, root: Node) -> Symb // and its rows ride on the carrier (v2.compiler.normalized_tree NormalizedTree import_bindings). // They are the same kind of row and bind by the same call. fn symbol_index_fill_module_bindings(index: SymbolIndex, tree: ModuleBindingSource) -> SymbolIndex { - match qualified_name_from_module_node(root: tree.root) { - Accepted { value: module_qn, diagnostics: _ } => - fold_list( - xs: tree.import_bindings, - empty: symbol_index_fill_alias_bindings( - index: index, - module_qn: module_qn, - root: tree.root - ), - cons: fn(acc, row) { - symbol_index_fill_binding_row(index: acc, module_qn: module_qn, row: row) - } - ) - Rejected { diagnostics: _ } => index - } + symbol_index_fill_bindings_to_fixed_point( + index: index, + pending: symbol_index_pending_of_source(tree: tree) + ) } fn symbol_index_fill_module_tree(index: SymbolIndex, tree: ModuleBindingSource) -> SymbolIndex { @@ -389,38 +471,47 @@ fn symbol_index_fill_module_root(index: SymbolIndex, root: Node) -> SymbolIndex } } -// TWO PASSES OVER THE ROOTS, BECAUSE ONE PASS MAKES FILE ORDER DECIDE WHAT A REFERENCE MEANS. -// A binding row reads its target out of the index, so under a single per-root pass a row pointing -// at a root not yet visited finds nothing and binds nothing, while the same row in a corpus listed -// the other way round binds fine. That is the corpus answering a resolution question with the order -// its files were walked in, which docs/plans/namespace-resolution-design.md section 13 rules out as -// a class -- and it is the quiet kind of order dependence, because the loser is an absence rather -// than a wrong answer. Separating the passes removes the dependence by CONSTRUCTION rather than by -// sorting the roots into some order that happens to work: after pass A every declaration any row -// could name is present, so pass B's result is the same set for every permutation of the input. -// THE PLURAL NODE-ONLY DOOR. Same statement as the singular one -- no import rows exist in a -// grafted Node -- and the same two passes, so a census over a root list does not depend on the -// order the list came in either. +// DECLARATIONS FIRST, THEN EVERY BINDING ROW IN THE CORPUS TO A FIXED POINT. Two things make a +// binding row's result depend on the order the roots arrived in, and they need different repairs. +// A row whose target is a DECLARATION is fixed by the split: pass A writes every declaration in the +// corpus before any row is tried. A row whose target is another row's BINDING -- a re-export chain +// -- is not, because collecting rows per root and binding them as you go still gives each row one +// attempt, in root order. That is the residue review 69735 found in the first version of this +// function, and it is live in this corpus, not hypothetical: `v2.compiler.normalize` imports +// `Absent` from `v2.std.collection`, which imports it from `v2.std.optional` and declares it +// nowhere. +// +// So the rows are gathered from EVERY root before any of them is applied, and retried until no +// further row can bind. Both order dependences are then gone for the same reason -- nothing is +// decided by when a root was visited -- and the permutation control +// (v2.test.claim.resolve.namespace_candidate_rule) carries a chained specimen so the claim is not +// larger than its evidence. fn symbol_index_fill_module_root_nodes( index: SymbolIndex, roots: FreeMonoid ) -> SymbolIndex { - fold_list( - xs: roots, - empty: fold_list( + symbol_index_fill_bindings_to_fixed_point( + index: fold_list( xs: roots, empty: index, cons: fn(acc, root) { symbol_index_fill_module_declarations(index: acc, root: root) } ), - cons: fn(acc, root) { - match qualified_name_from_module_node(root: root) { - Accepted { value: module_qn, diagnostics: _ } => - symbol_index_fill_alias_bindings(index: acc, module_qn: module_qn, root: root) - Rejected { diagnostics: _ } => acc + pending: fold_list( + xs: roots, + empty: Empty, + cons: fn(acc, root) { + match qualified_name_from_module_node(root: root) { + Accepted { value: module_qn, diagnostics: _ } => + list_append( + left: acc, + right: symbol_index_alias_rows_of(module_qn: module_qn, root: root) + ) + Rejected { diagnostics: _ } => acc + } } - } + ) ) } @@ -428,17 +519,20 @@ fn symbol_index_fill_module_roots( index: SymbolIndex, roots: FreeMonoid ) -> SymbolIndex { - fold_list( - xs: roots, - empty: fold_list( + symbol_index_fill_bindings_to_fixed_point( + index: fold_list( xs: roots, empty: index, cons: fn(acc, tree) { symbol_index_fill_module_declarations(index: acc, root: tree.root) } ), - cons: fn(acc, tree) { - symbol_index_fill_module_bindings(index: acc, tree: tree) - } + pending: fold_list( + xs: roots, + empty: Empty, + cons: fn(acc, tree) { + list_append(left: acc, right: symbol_index_pending_of_source(tree: tree)) + } + ) ) } diff --git a/src/v2/std/namespace_alias.dag b/src/v2/std/namespace_alias.dag index d92cead284e..74d59f68b30 100644 --- a/src/v2/std/namespace_alias.dag +++ b/src/v2/std/namespace_alias.dag @@ -26,3 +26,13 @@ type ModuleBindingSource { root: Node import_bindings: FreeMonoid } + +// A BINDING ROW WAITING FOR ITS TARGET, carried with the position it binds at. Pass B cannot bind a +// row until the row's TARGET is in the index, and a target is not always a declaration: a re-export +// chain (`m.b` imports `X` from `m.c`, `m.a` imports `X` from `m.b`) makes one row's target another +// row's binding. So the rows that could not bind yet are carried forward as values and retried, +// rather than being dropped in the order the roots happened to arrive in. +type PendingBinding { + module_qn: QualifiedName + row: AliasBindingRow +} diff --git a/src/v2/test/claim/resolve/namespace_candidate_rule_test.dag b/src/v2/test/claim/resolve/namespace_candidate_rule_test.dag index 02d80b06694..4fe2b49bb53 100644 --- a/src/v2/test/claim/resolve/namespace_candidate_rule_test.dag +++ b/src/v2/test/claim/resolve/namespace_candidate_rule_test.dag @@ -127,6 +127,20 @@ type NcrTwin = Bool fn ncr_shadow_use(p: NcrTwin) -> Bool { p } " +// A RE-EXPORT CHAIN, WHICH IS WHERE THE ORDER-INDEPENDENCE CLAIM WAS TOO LARGE FOR ITS EVIDENCE +// (review 69735). `ncr_relay` imports `NcrTwin` and declares nothing, so its own binding is what +// `ncr_chain` imports -- one row's target is another row's BINDING, not a declaration. Splitting +// declarations from bindings does not order these two: the relay's row has to bind before the +// chain's can. The specimen is the corpus's own shape, where v2.compiler.normalize imports `Absent` +// from v2.std.collection, which imports it from v2.std.optional and declares it nowhere. +// +// IT IS PLACED SO THE PERMUTATION MOVES IT. In ncr_ingest the relay precedes the chain and in the +// permuted ingest it follows it, so a fill that gave each row one attempt in root order would bind +// this in one and silently not in the other. +data ncr_relay_source: String = "module v2.ncr_relay\n\nimport v2.ncr_home_a { NcrTwin }\n" + +data ncr_chain_source: String = "module v2.ncr_chain\n\nimport v2.std.logic { Bool }\nimport v2.ncr_relay { NcrTwin }\n\nfn ncr_chain_use(p: NcrTwin) -> Bool { p }\n" + fn ncr_read(source: String, id: Symbol, unit: Symbol, path: String) -> DagSourceReadWitness { DagSourceReadWitness { source: Medium { carried: source, fidelity: Lossless }, @@ -136,6 +150,14 @@ fn ncr_read(source: String, id: Symbol, unit: Symbol, path: String) -> DagSource } } +fn ncr_relay_read() -> DagSourceReadWitness { + ncr_read(source: ncr_relay_source, id: ^ncr_relay_artifact, unit: ^ncr_relay_cu, path: "dag/ncr_relay.dag") +} + +fn ncr_chain_read() -> DagSourceReadWitness { + ncr_read(source: ncr_chain_source, id: ^ncr_chain_artifact, unit: ^ncr_chain_cu, path: "dag/ncr_chain.dag") +} + fn ncr_shadow_read() -> DagSourceReadWitness { ncr_read(source: ncr_shadow_source, id: ^ncr_shadow_artifact, unit: ^ncr_shadow_cu, path: "dag/ncr_shadow.dag") } @@ -191,7 +213,13 @@ data ncr_ingest: SourceRootIngest = Cons { head: ncr_disp_other_read(), tail: Cons { head: ncr_disp_user_read(), - tail: Cons { head: ncr_shadow_read(), tail: Empty } + tail: Cons { + head: ncr_shadow_read(), + tail: Cons { + head: ncr_relay_read(), + tail: Cons { head: ncr_chain_read(), tail: Empty } + } + } } } } @@ -217,7 +245,13 @@ data ncr_ingest_permuted: SourceRootIngest = Cons { head: ncr_disp_other_read(), tail: Cons { head: ncr_disp_home_read(), - tail: Cons { head: ncr_shadow_read(), tail: Empty } + tail: Cons { + head: ncr_shadow_read(), + tail: Cons { + head: ncr_chain_read(), + tail: Cons { head: ncr_relay_read(), tail: Empty } + } + } } } } @@ -314,11 +348,13 @@ type NcrOutcomes home_a: NcrVerdict disp_user: NcrVerdict shadow: NcrVerdict + chain: NcrVerdict importer_permuted: NcrVerdict double_permuted: NcrVerdict unbound_permuted: NcrVerdict disp_user_permuted: NcrVerdict shadow_permuted: NcrVerdict + chain_permuted: NcrVerdict } fn ncr_verdicts_for(ingest: SourceRootIngest, module_name: String) -> Optional { @@ -347,6 +383,8 @@ fn ncr_outcomes() -> NcrOutcomes { importer: ncr_verdict_of(outcome: ncr_outcome_in(context: context, index: index, ingest: ncr_ingest, module_name: "v2.ncr_importer")), double: ncr_verdict_of(outcome: ncr_outcome_in(context: context, index: index, ingest: ncr_ingest, module_name: "v2.ncr_double")), unbound: ncr_verdict_of(outcome: ncr_outcome_in(context: context, index: index, ingest: ncr_ingest, module_name: "v2.ncr_unbound")), + chain: ncr_verdict_of(outcome: ncr_outcome_in(context: context, index: index, ingest: ncr_ingest, module_name: "v2.ncr_chain")), + chain_permuted: ncr_verdict_of(outcome: ncr_outcome_in(context: permuted_context, index: permuted_index, ingest: ncr_ingest_permuted, module_name: "v2.ncr_chain")), shadow: ncr_verdict_of(outcome: ncr_outcome_in(context: context, index: index, ingest: ncr_ingest, module_name: "v2.ncr_shadow")), shadow_permuted: ncr_verdict_of(outcome: ncr_outcome_in(context: permuted_context, index: permuted_index, ingest: ncr_ingest_permuted, module_name: "v2.ncr_shadow")), disp_user: ncr_verdict_of(outcome: ncr_outcome_in(context: context, index: index, ingest: ncr_ingest, module_name: "v2.ncr_disp_user")), @@ -432,7 +470,7 @@ fn ncr_verdict_same(left: NcrVerdict, right: NcrVerdict) -> Bool { test fn a_bound_sibling_declaration_resolves_by_containment_holds() -> Bool { match ncr_outcomes_warm { NcrContextRefused => false - NcrOutcomesDecided { importer: importer, double: _, unbound: _, home_a: home_a, disp_user: _, shadow: _, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _ } => + NcrOutcomesDecided { importer: importer, double: _, unbound: _, home_a: home_a, disp_user: _, shadow: _, chain: _, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _, chain_permuted: _ } => ncr_verdict_is_resolved(v: importer) && ncr_verdict_is_resolved(v: home_a) } } @@ -443,7 +481,7 @@ test fn a_bound_sibling_declaration_resolves_by_containment_holds() -> Bool { test fn a_genuine_ambiguity_refuses_naming_both_binders_holds() -> Bool { match ncr_outcomes_warm { NcrContextRefused => false - NcrOutcomesDecided { importer: _, double: double, unbound: _, home_a: _, disp_user: _, shadow: _, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _ } => + NcrOutcomesDecided { importer: _, double: double, unbound: _, home_a: _, disp_user: _, shadow: _, chain: _, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _, chain_permuted: _ } => ncr_verdict_refuses_with(v: double, reason: ^resolve_reason_ambiguous_symbol) && ncr_verdict_names(v: double, dotted: "v2.ncr_home_a.NcrTwin") && ncr_verdict_names(v: double, dotted: "v2.ncr_home_b.NcrTwin") @@ -459,7 +497,7 @@ test fn a_genuine_ambiguity_refuses_naming_both_binders_holds() -> Bool { test fn an_unbound_name_the_corpus_spells_twice_stays_unbound_holds() -> Bool { match ncr_outcomes_warm { NcrContextRefused => false - NcrOutcomesDecided { importer: _, double: _, unbound: unbound, home_a: _, disp_user: _, shadow: _, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _ } => + NcrOutcomesDecided { importer: _, double: _, unbound: unbound, home_a: _, disp_user: _, shadow: _, chain: _, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _, chain_permuted: _ } => ncr_verdict_refuses_with(v: unbound, reason: ^resolve_reason_unbound_symbol) } } @@ -479,17 +517,20 @@ test fn reordering_the_files_changes_no_verdict_holds() -> Bool { home_a: _, disp_user: disp_user, shadow: shadow, + chain: chain, importer_permuted: importer_permuted, double_permuted: double_permuted, unbound_permuted: unbound_permuted, disp_user_permuted: disp_user_permuted, - shadow_permuted: shadow_permuted + shadow_permuted: shadow_permuted, + chain_permuted: chain_permuted } => ncr_verdict_same(left: importer, right: importer_permuted) && ncr_verdict_same(left: double, right: double_permuted) && ncr_verdict_same(left: unbound, right: unbound_permuted) && ncr_verdict_same(left: disp_user, right: disp_user_permuted) && ncr_verdict_same(left: shadow, right: shadow_permuted) + && ncr_verdict_same(left: chain, right: chain_permuted) } } @@ -503,7 +544,7 @@ test fn reordering_the_files_changes_no_verdict_holds() -> Bool { test fn the_workload_bare_variant_reference_resolves_through_its_import_holds() -> Bool { match ncr_outcomes_warm { NcrContextRefused => false - NcrOutcomesDecided { importer: _, double: _, unbound: _, home_a: _, disp_user: disp_user, shadow: _, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _ } => + NcrOutcomesDecided { importer: _, double: _, unbound: _, home_a: _, disp_user: disp_user, shadow: _, chain: _, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _, chain_permuted: _ } => ncr_verdict_is_resolved(v: disp_user) } } @@ -515,10 +556,24 @@ test fn the_workload_bare_variant_reference_resolves_through_its_import_holds() test fn declaring_and_importing_one_name_refuses_naming_both_holds() -> Bool { match ncr_outcomes_warm { NcrContextRefused => false - NcrOutcomesDecided { importer: _, double: _, unbound: _, home_a: _, disp_user: _, shadow: shadow, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _ } => + NcrOutcomesDecided { importer: _, double: _, unbound: _, home_a: _, disp_user: _, shadow: shadow, chain: _, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _, chain_permuted: _ } => ncr_verdict_refuses_with(v: shadow, reason: ^resolve_reason_ambiguous_symbol) && ncr_verdict_names(v: shadow, dotted: "v2.ncr_shadow.NcrTwin") && ncr_verdict_names(v: shadow, dotted: "v2.ncr_home_a.NcrTwin") && ncr_verdict_competing_count(v: shadow) == 2 } } + +// A RE-EXPORT CHAIN RESOLVES, AND RESOLVES IN EITHER ROOT ORDER. `v2.ncr_chain` reaches +// `v2.ncr_home_a.NcrTwin` through `v2.ncr_relay`, which binds it without declaring it -- so this +// row's target is another row's binding and cannot be present until that row has bound. The +// permutation arm above carries the same pair with the two roots swapped; this arm is what says the +// chain resolves AT ALL, which "unchanged under permutation" would not (two matching unbound +// refusals are also unchanged). +test fn a_re_export_chain_resolves_through_its_relay_holds() -> Bool { + match ncr_outcomes_warm { + NcrContextRefused => false + NcrOutcomesDecided { importer: _, double: _, unbound: _, home_a: _, disp_user: _, shadow: _, chain: chain, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _, chain_permuted: _ } => + ncr_verdict_is_resolved(v: chain) + } +} From 9cef42ad0333f6d825483abd2dc03d1f56e6d612 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Mon, 21 Sep 2026 22:49:30 +0000 Subject: [PATCH 04/17] A contested position answers nothing, on both spellings, and four dangling declarations go Review 69760 on #12009, both findings verified against the code and fixed. ONE RULE MAY NOT HAVE TWO ANSWERS DECIDED BY HOW THE REFERENCE WAS SPELLED. symbol_index_bind_at records every claimant of a binding position, but `entries` still held whichever claimant was applied first, and only the LEXICAL collector consulted the claimant list. So try_resolve_qualified_name_node read that slot directly: a module that declares `N` and imports `N` refused as ambiguous when `N` was spelled bare, and resolved to a fill-order pick with NO diagnostic when the same position was spelled by its absolute path. The contest is now checked at the single read every consumer already goes through rather than at each door. A contested path reads as ABSENT, so no consumer can be handed a pick; the door that must say WHY asks symbol_index_absolute_candidates first and refuses naming both binders, and every other reader gets an absence and refuses at its own boundary. That split needed one more distinction than the first attempt had: the candidate collector is asking about STORAGE, not meaning, and reading it through the contest-aware answer handed it an absence for exactly the claimants it was collecting -- silently collapsing a two-binder refusal back to one. symbol_index_entry_at is the raw slot; symbol_index_lookup is what a reference is entitled to be told. The existing arm caught that regression, which is the second time these controls have caught a fail-open in their own fix. New arm a_qualified_reference_to_a_contested_position_refuses_naming_both, and its mutation control run: reinstating the raw slot read on the qualified route reds it and nothing else. It reaches the SAME two claimants as the bare-name arm by the other spelling the language admits, and the point is that the two now agree. DANGLING DECLARATIONS DELETED (DESIGN section 3c). symbol_index_fill_import_binding had no call site at all; symbol_index_fill_alias_binding and symbol_index_try_lexical_at were orphaned BY THIS DIFF when symbol_index_alias_rows_of and symbol_index_candidates_at replaced their callers. symbol_index_fill_binding_row became orphaned by the same deletion and goes with them, along with two imports that no longer resolve to a use. 8/8 in v2.test.claim.resolve.namespace_candidate_rule; four mutation controls now, each reddening only the arm that owns its claim. Co-Authored-By: Claude Opus 5 (1M context) --- src/v2/compiler/03_resolve.dag | 34 ++++++++++ src/v2/compiler/symbol_index_fill.dag | 62 +------------------ src/v2/std/symbol_index.dag | 61 ++++++++++++++---- .../resolve/namespace_candidate_rule_test.dag | 58 ++++++++++++++--- 4 files changed, 133 insertions(+), 82 deletions(-) diff --git a/src/v2/compiler/03_resolve.dag b/src/v2/compiler/03_resolve.dag index 454516eecfc..c62ebaf5dc5 100644 --- a/src/v2/compiler/03_resolve.dag +++ b/src/v2/compiler/03_resolve.dag @@ -446,6 +446,7 @@ fn ambiguous_symbol_diagnostic(n: Node) -> Diagnostic { // still holds, so "competing declarations where available" is no longer a caveat this type needs. type AmbiguousLookupClass = AmbiguousOnLexicalChain { candidates: FreeMonoid } + | AmbiguousAtBindingPosition { candidates: FreeMonoid } | AmbiguousQualifiedHeadShadowsAbsolute { path: QualifiedName } fn ambiguous_lookup_class_diagnostic(n: Node, reason: Symbol) -> Diagnostic { @@ -491,6 +492,20 @@ fn ambiguous_symbol_diagnostics(n: Node, class: AmbiguousLookupClass) -> NonEmpt item: fatal ) } + AmbiguousAtBindingPosition { candidates: candidates } => + NonEmptyDiagnostics { + head: ambiguous_lookup_class_diagnostic(n: n, reason: ^resolve_ambiguous_at_binding_position), + tail: list_snoc_item( + xs: fold_list( + xs: candidates, + empty: Empty, + cons: fn(acc, candidate) { + list_snoc_item(xs: acc, item: ambiguous_competing_declaration_diagnostic(path: candidate.path)) + } + ), + item: fatal + ) + } AmbiguousQualifiedHeadShadowsAbsolute { path: path } => NonEmptyDiagnostics { head: ambiguous_lookup_class_diagnostic(n: n, reason: ^resolve_ambiguous_qualified_head_shadows_absolute), @@ -623,6 +638,24 @@ fn try_resolve_qualified_name_node(ctx: ResolveContext, n: Node) -> Optional optional_present(value: Rejected { diagnostics: rejected_with_pending(pending: pending, rejected: r) }) Accepted { value: head_bound, diagnostics: chain_pending } => + if length(xs: symbol_index_absolute_candidates(index: ctx.namespace.symbol_index, qualified_path: path)) > 1 { + 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: symbol_index_absolute_candidates( + index: ctx.namespace.symbol_index, + qualified_path: path + ) + } + ) + ) + } + ) + } else { match symbol_index_lookup(index: ctx.namespace.symbol_index, qualified_path: path) { Present { value: resolved_node } => if head_bound { @@ -680,6 +713,7 @@ fn try_resolve_qualified_name_node(ctx: ResolveContext, n: Node) -> Optional Map { ) } -// ONE ROW, ONE BINDING, WHATEVER SURFACE FORM WROTE IT. An `alias` decl and one named item of an -// `import` both arrive here as an AliasBindingRow, so this is the single place a name is bound at a -// position that is not its declaration's own. It binds through symbol_index_bind_at rather than -// symbol_index_insert because a row may land on a position a declaration already holds -- a module -// that declares `N` and also imports `N` -- and the map has one slot: an insert there would let the -// fill order pick the meaning silently. -// -// A ROW WHOSE TARGET IS NOT IN THE INDEX BINDS NOTHING, and that is not a swallowed error. The -// refusal docs/plans/namespace-resolution-design.md section 13 asks for is raised AT THE REFERENCE -// SITE, not at the declaration: with no binding at the importing position, a reference to the name -// is off its own ancestor chain and the resolver refuses it, located, with the name the source -// actually wrote. Refusing here instead would report the importing MODULE as the failing subject -// and lose every reference site inside it. -fn symbol_index_fill_binding_row( - index: SymbolIndex, - module_qn: QualifiedName, - row: AliasBindingRow -) -> SymbolIndex { - match symbol_index_lookup(index: index, qualified_path: row.target) { - Present { value: resolved } => - symbol_index_bind_at( - index: index, - binding_path: qualified_name_snoc(qn: module_qn, segment: row.binding), - declaring_path: row.target, - resolved: resolved - ) - Absent => index - } -} -fn symbol_index_fill_alias_binding( - index: SymbolIndex, - module_qn: QualifiedName, - root: Node, - decl: Node -) -> SymbolIndex { - match alias_binding_row_from_decl_node(decl: decl, module_root: root) { - Rejected { diagnostics: _ } => index - Accepted { value: row, diagnostics: _ } => - symbol_index_fill_binding_row(index: index, module_qn: module_qn, row: row) - } -} -fn symbol_index_fill_import_binding( - index: SymbolIndex, - module_qn: QualifiedName, - root: Node, - decl: Node -) -> SymbolIndex { - match import_binding_rows_from_decl_node(decl: decl, module_root: root) { - Rejected { diagnostics: _ } => index - Accepted { value: rows, diagnostics: _ } => - fold_list( - xs: rows, - empty: index, - cons: fn(acc, row) { - symbol_index_fill_binding_row(index: acc, module_qn: module_qn, row: row) - } - ) - } -} // EVERY ROW THIS MODULE BINDS, AS VALUES, BEFORE ANY OF THEM IS APPLIED. Collecting is separated // from binding because a row cannot be bound until its target is present, and which rows are diff --git a/src/v2/std/symbol_index.dag b/src/v2/std/symbol_index.dag index 0ce1db7f061..b3ac5ebca3a 100644 --- a/src/v2/std/symbol_index.dag +++ b/src/v2/std/symbol_index.dag @@ -86,13 +86,58 @@ fn empty_symbol_index() -> SymbolIndex { } } -fn symbol_index_lookup(index: SymbolIndex, qualified_path: QualifiedName) -> Optional { +// A CONTESTED POSITION ANSWERS NOTHING, AND THAT IS THE WHOLE REASON THIS IS NOT A PLAIN MAP READ. +// `entries` holds ONE node per path, so when two declarations claim one binding position it holds +// whichever was applied first -- a fill-order winner. That is harmless only if nothing reads it +// without asking who else claimed the position, and review 69760 on gunbc#12009 found the readers +// that did: the qualified-path door in v2.compiler.resolve read `entries` directly, so an absolute +// reference to a doubly-bound name resolved to the first binder with NO diagnostic, while the same +// name spelled bare refused as ambiguous. One rule, two answers, decided by how the reference was +// spelled. +// +// So the CONTEST IS CHECKED HERE, at the single read every consumer already goes through, rather +// than at each door. A contested path reads as ABSENT: no consumer can be handed a pick. The door +// that must say WHY -- the qualified one -- asks symbol_index_absolute_candidates first and refuses +// with both binders named; every other reader gets an absence and refuses at its own boundary, +// which is fail-closed rather than a silent winner. +// THE RAW SLOT, READ BY THE THINGS THAT ARE ASKING ABOUT STORAGE RATHER THAN ABOUT MEANING. The +// candidate collector needs the node behind EACH claimant of a contested position, and the +// contest-aware answer below would hand it an absence for exactly those -- which silently collapsed +// a two-binder refusal back to one binder the first time this split was written. `entries` is where +// a declaration is stored; `symbol_index_lookup` is what a reference is entitled to be told. +fn symbol_index_entry_at(index: SymbolIndex, qualified_path: QualifiedName) -> Optional { match v2.std.collection.map_get(index.entries, qualified_path) { Accepted { value: opt, diagnostics: _ } => opt Rejected { diagnostics: _ } => optional_absent() } } +fn symbol_index_lookup(index: SymbolIndex, qualified_path: QualifiedName) -> Optional { + if length(xs: symbol_index_bound_declarings(index: index, binding_path: qualified_path)) > 1 { + optional_absent() + } else { + symbol_index_entry_at(index: index, qualified_path: qualified_path) + } +} + +// THE CLAIMANTS OF AN ABSOLUTE PATH, for the door that binds by the whole path rather than by +// walking a chain. Same fold the chain collector uses, asked at the path's own position, so the two +// doors cannot disagree about who claims a position. +fn symbol_index_absolute_candidates( + index: SymbolIndex, + qualified_path: QualifiedName +) -> FreeMonoid { + match qualified_name_last_segment(qn: qualified_path) { + Absent => Empty + Present { value: leaf } => + symbol_index_candidates_at( + index: index, + position: qualified_name_init(qn: qualified_path), + name: leaf + ) + } +} + fn symbol_index_track_global_bare( index: SymbolIndex, qualified_path: QualifiedName, @@ -162,14 +207,6 @@ fn symbol_index_global_unique_lookup(index: SymbolIndex, name: Symbol) -> Global } } -fn symbol_index_try_lexical_at( - index: SymbolIndex, - position: QualifiedName, - name: Symbol -) -> Optional { - let candidate_path = qualified_name_snoc(qn: position, segment: name) - symbol_index_lookup(index: index, qualified_path: candidate_path) -} // BINDING A NAME AT A POSITION IS A DIFFERENT OPERATION FROM RECORDING A DECLARATION, and this is // the one writer for it. `symbol_index_insert` answers "this declaration lives at this path", where @@ -200,7 +237,7 @@ fn symbol_index_claimants_at( ) -> FreeMonoid { let recorded = symbol_index_bound_declarings(index: index, binding_path: binding_path) if is_empty(xs: recorded) { - match symbol_index_lookup(index: index, qualified_path: binding_path) { + match symbol_index_entry_at(index: index, qualified_path: binding_path) { Present { value: _ } => Cons { head: binding_path, tail: Empty } Absent => Empty } @@ -265,7 +302,7 @@ fn symbol_index_candidates_at( let binding_path = qualified_name_snoc(qn: position, segment: name) let bound = symbol_index_bound_declarings(index: index, binding_path: binding_path) if is_empty(xs: bound) { - match symbol_index_lookup(index: index, qualified_path: binding_path) { + match symbol_index_entry_at(index: index, qualified_path: binding_path) { Present { value: node } => Cons { head: LexicalBindingCandidate { path: binding_path, node: node }, @@ -278,7 +315,7 @@ fn symbol_index_candidates_at( xs: bound, empty: Empty, cons: fn(acc, declaring) { - match symbol_index_lookup(index: index, qualified_path: declaring) { + match symbol_index_entry_at(index: index, qualified_path: declaring) { Present { value: node } => list_snoc_item( xs: acc, diff --git a/src/v2/test/claim/resolve/namespace_candidate_rule_test.dag b/src/v2/test/claim/resolve/namespace_candidate_rule_test.dag index 4fe2b49bb53..d1510167da8 100644 --- a/src/v2/test/claim/resolve/namespace_candidate_rule_test.dag +++ b/src/v2/test/claim/resolve/namespace_candidate_rule_test.dag @@ -141,6 +141,14 @@ data ncr_relay_source: String = "module v2.ncr_relay\n\nimport v2.ncr_home_a { N data ncr_chain_source: String = "module v2.ncr_chain\n\nimport v2.std.logic { Bool }\nimport v2.ncr_relay { NcrTwin }\n\nfn ncr_chain_use(p: NcrTwin) -> Bool { p }\n" +// THE SAME CONTESTED POSITION, REACHED BY ITS ABSOLUTE PATH. `v2.ncr_shadow` declares `NcrTwin` +// and imports it, so its position has two claimants; this module names that position by its whole +// path instead of by a bare name on a chain. Before review 69760 the two spellings disagreed: the +// bare one refused as ambiguous while the qualified one read the storage slot directly and resolved +// to whichever claimant the fill applied first, with no diagnostic. One rule may not have two +// answers decided by how the reference was spelled. +data ncr_qual_source: String = "module v2.ncr_qual\n\nimport v2.std.logic { Bool }\n\nfn ncr_qual_use(p: v2.ncr_shadow.NcrTwin) -> Bool { p }\n" + fn ncr_read(source: String, id: Symbol, unit: Symbol, path: String) -> DagSourceReadWitness { DagSourceReadWitness { source: Medium { carried: source, fidelity: Lossless }, @@ -150,6 +158,10 @@ fn ncr_read(source: String, id: Symbol, unit: Symbol, path: String) -> DagSource } } +fn ncr_qual_read() -> DagSourceReadWitness { + ncr_read(source: ncr_qual_source, id: ^ncr_qual_artifact, unit: ^ncr_qual_cu, path: "dag/ncr_qual.dag") +} + fn ncr_relay_read() -> DagSourceReadWitness { ncr_read(source: ncr_relay_source, id: ^ncr_relay_artifact, unit: ^ncr_relay_cu, path: "dag/ncr_relay.dag") } @@ -217,7 +229,10 @@ data ncr_ingest: SourceRootIngest = Cons { head: ncr_shadow_read(), tail: Cons { head: ncr_relay_read(), - tail: Cons { head: ncr_chain_read(), tail: Empty } + tail: Cons { + head: ncr_chain_read(), + tail: Cons { head: ncr_qual_read(), tail: Empty } + } } } } @@ -249,7 +264,10 @@ data ncr_ingest_permuted: SourceRootIngest = Cons { head: ncr_shadow_read(), tail: Cons { head: ncr_chain_read(), - tail: Cons { head: ncr_relay_read(), tail: Empty } + tail: Cons { + head: ncr_relay_read(), + tail: Cons { head: ncr_qual_read(), tail: Empty } + } } } } @@ -349,12 +367,14 @@ type NcrOutcomes disp_user: NcrVerdict shadow: NcrVerdict chain: NcrVerdict + qual: NcrVerdict importer_permuted: NcrVerdict double_permuted: NcrVerdict unbound_permuted: NcrVerdict disp_user_permuted: NcrVerdict shadow_permuted: NcrVerdict chain_permuted: NcrVerdict + qual_permuted: NcrVerdict } fn ncr_verdicts_for(ingest: SourceRootIngest, module_name: String) -> Optional { @@ -383,6 +403,8 @@ fn ncr_outcomes() -> NcrOutcomes { importer: ncr_verdict_of(outcome: ncr_outcome_in(context: context, index: index, ingest: ncr_ingest, module_name: "v2.ncr_importer")), double: ncr_verdict_of(outcome: ncr_outcome_in(context: context, index: index, ingest: ncr_ingest, module_name: "v2.ncr_double")), unbound: ncr_verdict_of(outcome: ncr_outcome_in(context: context, index: index, ingest: ncr_ingest, module_name: "v2.ncr_unbound")), + qual: ncr_verdict_of(outcome: ncr_outcome_in(context: context, index: index, ingest: ncr_ingest, module_name: "v2.ncr_qual")), + qual_permuted: ncr_verdict_of(outcome: ncr_outcome_in(context: permuted_context, index: permuted_index, ingest: ncr_ingest_permuted, module_name: "v2.ncr_qual")), chain: ncr_verdict_of(outcome: ncr_outcome_in(context: context, index: index, ingest: ncr_ingest, module_name: "v2.ncr_chain")), chain_permuted: ncr_verdict_of(outcome: ncr_outcome_in(context: permuted_context, index: permuted_index, ingest: ncr_ingest_permuted, module_name: "v2.ncr_chain")), shadow: ncr_verdict_of(outcome: ncr_outcome_in(context: context, index: index, ingest: ncr_ingest, module_name: "v2.ncr_shadow")), @@ -470,7 +492,7 @@ fn ncr_verdict_same(left: NcrVerdict, right: NcrVerdict) -> Bool { test fn a_bound_sibling_declaration_resolves_by_containment_holds() -> Bool { match ncr_outcomes_warm { NcrContextRefused => false - NcrOutcomesDecided { importer: importer, double: _, unbound: _, home_a: home_a, disp_user: _, shadow: _, chain: _, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _, chain_permuted: _ } => + NcrOutcomesDecided { importer: importer, double: _, unbound: _, home_a: home_a, disp_user: _, shadow: _, chain: _, qual: _, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _, chain_permuted: _, qual_permuted: _ } => ncr_verdict_is_resolved(v: importer) && ncr_verdict_is_resolved(v: home_a) } } @@ -481,7 +503,7 @@ test fn a_bound_sibling_declaration_resolves_by_containment_holds() -> Bool { test fn a_genuine_ambiguity_refuses_naming_both_binders_holds() -> Bool { match ncr_outcomes_warm { NcrContextRefused => false - NcrOutcomesDecided { importer: _, double: double, unbound: _, home_a: _, disp_user: _, shadow: _, chain: _, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _, chain_permuted: _ } => + NcrOutcomesDecided { importer: _, double: double, unbound: _, home_a: _, disp_user: _, shadow: _, chain: _, qual: _, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _, chain_permuted: _, qual_permuted: _ } => ncr_verdict_refuses_with(v: double, reason: ^resolve_reason_ambiguous_symbol) && ncr_verdict_names(v: double, dotted: "v2.ncr_home_a.NcrTwin") && ncr_verdict_names(v: double, dotted: "v2.ncr_home_b.NcrTwin") @@ -497,7 +519,7 @@ test fn a_genuine_ambiguity_refuses_naming_both_binders_holds() -> Bool { test fn an_unbound_name_the_corpus_spells_twice_stays_unbound_holds() -> Bool { match ncr_outcomes_warm { NcrContextRefused => false - NcrOutcomesDecided { importer: _, double: _, unbound: unbound, home_a: _, disp_user: _, shadow: _, chain: _, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _, chain_permuted: _ } => + NcrOutcomesDecided { importer: _, double: _, unbound: unbound, home_a: _, disp_user: _, shadow: _, chain: _, qual: _, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _, chain_permuted: _, qual_permuted: _ } => ncr_verdict_refuses_with(v: unbound, reason: ^resolve_reason_unbound_symbol) } } @@ -518,12 +540,14 @@ test fn reordering_the_files_changes_no_verdict_holds() -> Bool { disp_user: disp_user, shadow: shadow, chain: chain, + qual: qual, importer_permuted: importer_permuted, double_permuted: double_permuted, unbound_permuted: unbound_permuted, disp_user_permuted: disp_user_permuted, shadow_permuted: shadow_permuted, - chain_permuted: chain_permuted + chain_permuted: chain_permuted, + qual_permuted: qual_permuted } => ncr_verdict_same(left: importer, right: importer_permuted) && ncr_verdict_same(left: double, right: double_permuted) @@ -531,6 +555,7 @@ test fn reordering_the_files_changes_no_verdict_holds() -> Bool { && ncr_verdict_same(left: disp_user, right: disp_user_permuted) && ncr_verdict_same(left: shadow, right: shadow_permuted) && ncr_verdict_same(left: chain, right: chain_permuted) + && ncr_verdict_same(left: qual, right: qual_permuted) } } @@ -544,7 +569,7 @@ test fn reordering_the_files_changes_no_verdict_holds() -> Bool { test fn the_workload_bare_variant_reference_resolves_through_its_import_holds() -> Bool { match ncr_outcomes_warm { NcrContextRefused => false - NcrOutcomesDecided { importer: _, double: _, unbound: _, home_a: _, disp_user: disp_user, shadow: _, chain: _, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _, chain_permuted: _ } => + NcrOutcomesDecided { importer: _, double: _, unbound: _, home_a: _, disp_user: disp_user, shadow: _, chain: _, qual: _, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _, chain_permuted: _, qual_permuted: _ } => ncr_verdict_is_resolved(v: disp_user) } } @@ -556,7 +581,7 @@ test fn the_workload_bare_variant_reference_resolves_through_its_import_holds() test fn declaring_and_importing_one_name_refuses_naming_both_holds() -> Bool { match ncr_outcomes_warm { NcrContextRefused => false - NcrOutcomesDecided { importer: _, double: _, unbound: _, home_a: _, disp_user: _, shadow: shadow, chain: _, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _, chain_permuted: _ } => + NcrOutcomesDecided { importer: _, double: _, unbound: _, home_a: _, disp_user: _, shadow: shadow, chain: _, qual: _, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _, chain_permuted: _, qual_permuted: _ } => ncr_verdict_refuses_with(v: shadow, reason: ^resolve_reason_ambiguous_symbol) && ncr_verdict_names(v: shadow, dotted: "v2.ncr_shadow.NcrTwin") && ncr_verdict_names(v: shadow, dotted: "v2.ncr_home_a.NcrTwin") @@ -573,7 +598,22 @@ test fn declaring_and_importing_one_name_refuses_naming_both_holds() -> Bool { test fn a_re_export_chain_resolves_through_its_relay_holds() -> Bool { match ncr_outcomes_warm { NcrContextRefused => false - NcrOutcomesDecided { importer: _, double: _, unbound: _, home_a: _, disp_user: _, shadow: _, chain: chain, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _, chain_permuted: _ } => + NcrOutcomesDecided { importer: _, double: _, unbound: _, home_a: _, disp_user: _, shadow: _, chain: chain, qual: _, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _, chain_permuted: _, qual_permuted: _ } => ncr_verdict_is_resolved(v: chain) } } + +// A QUALIFIED REFERENCE TO A CONTESTED POSITION REFUSES, NAMING BOTH. The bare-name arm above and +// this one reach the SAME two claimants by the two spellings the language admits, and the point is +// that they now agree. The count is asserted because resolving to either claimant is what this +// closes -- and that is how it failed: silently, with no diagnostic at all. +test fn a_qualified_reference_to_a_contested_position_refuses_naming_both_holds() -> Bool { + match ncr_outcomes_warm { + NcrContextRefused => false + NcrOutcomesDecided { importer: _, double: _, unbound: _, home_a: _, disp_user: _, shadow: _, chain: _, qual: qual, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _, chain_permuted: _, qual_permuted: _ } => + ncr_verdict_refuses_with(v: qual, reason: ^resolve_reason_ambiguous_symbol) + && ncr_verdict_names(v: qual, dotted: "v2.ncr_shadow.NcrTwin") + && ncr_verdict_names(v: qual, dotted: "v2.ncr_home_a.NcrTwin") + && ncr_verdict_competing_count(v: qual) == 2 + } +} From 808e5c6d1125ca304b1ec83e3a175165f1e8f66c Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Tue, 22 Sep 2026 00:17:42 +0000 Subject: [PATCH 05/17] Two more dangling declarations, a comment describing a route that does not execute, and a stale import that is no longer inert Review 69789 on #12009, both findings verified by call-site count and fixed. symbol_index_fill_module_tree had no caller anywhere in the tree, and symbol_index_fill_module_bindings was called only from it -- new residue of exactly the class an earlier commit here is titled after, left behind when the fixed-point refactor moved the corpus-wide fill onto symbol_index_pending_of_source. The comment beside them was worse than the code: it named symbol_index_fill_module_tree as the door "a caller holding a NormalizedTree goes through", and no caller does. It now names the door that executes and says why single-root and whole-corpus are deliberately not symmetric -- a binding row's target may be another root's declaration, so binding ONE root in isolation can only ever see what that root itself declares. The stale `normalized_tree_roots_to_nodes` import in 03_name_resolve is dropped. The FUNCTION stays: it has consumers in six other modules, so only the import was stale. THE REVIEWER'S SECOND POINT IS RECORDED ON THE CARRIER, because it is a consequence of this change rather than a tidiness matter. Before the transmutation, an import block naming something the module never used was bookkeeping. Now it MINTS A BINDING at the importing position and is a claimant like any other, so a stale name colliding with something else bound there is an ambiguity the resolver refuses. That is the ruled model working -- section 13 makes an alias an ordinary binding node and calls an unused one lintable dead code -- but it moves stale imports from harmless to load-bearing, and an author deleting a use must now delete its import row with it. The note sits beside import_binding_rows_from_decl_node, which causes it. 8/8 controls unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- src/v2/compiler/03_name_resolve.dag | 2 +- src/v2/compiler/symbol_index_fill.dag | 24 +++++------------------- src/v2/extdeps/languages/dag.dag | 9 +++++++++ 3 files changed, 15 insertions(+), 20 deletions(-) diff --git a/src/v2/compiler/03_name_resolve.dag b/src/v2/compiler/03_name_resolve.dag index 56980cb0e3d..cb288f4fe0e 100644 --- a/src/v2/compiler/03_name_resolve.dag +++ b/src/v2/compiler/03_name_resolve.dag @@ -3,7 +3,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.normalized_tree { NormalizedTree, normalized_tree_roots_to_binding_sources, normalized_tree_roots_to_nodes } +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 } import v2.compiler.resolve { diff --git a/src/v2/compiler/symbol_index_fill.dag b/src/v2/compiler/symbol_index_fill.dag index fe79c95e5dd..77ea428caf4 100644 --- a/src/v2/compiler/symbol_index_fill.dag +++ b/src/v2/compiler/symbol_index_fill.dag @@ -377,28 +377,14 @@ fn symbol_index_fill_module_declarations(index: SymbolIndex, root: Node) -> Symb // PASS B -- WHAT THIS ROOT BINDS FROM ELSEWHERE. Every row here reads ANOTHER root's declarations // out of the index, so it can only be run once every root's pass A has been. The two sources of // rows differ only in where they SURVIVED: an `alias` decl is grafted into the tree as an ordinary -// named unit and is still readable from the root, while an import decl was dissolved by the graft -// and its rows ride on the carrier (v2.compiler.normalized_tree NormalizedTree import_bindings). -// They are the same kind of row and bind by the same call. -fn symbol_index_fill_module_bindings(index: SymbolIndex, tree: ModuleBindingSource) -> SymbolIndex { - symbol_index_fill_bindings_to_fixed_point( - index: index, - pending: symbol_index_pending_of_source(tree: tree) - ) -} - -fn symbol_index_fill_module_tree(index: SymbolIndex, tree: ModuleBindingSource) -> SymbolIndex { - symbol_index_fill_module_bindings( - index: symbol_index_fill_module_declarations(index: index, root: tree.root), - tree: tree - ) -} - // THE NODE-ONLY DOOR, AND ITS NAME SAYS WHAT IT CANNOT DO. A caller holding only a grafted Node has // no import rows to offer -- the graft dissolved them and they are not recoverable from what is // left -- so this fills declarations and the alias rows still present in the tree, and no import -// bindings. That is a statement about the input, not a default: a caller holding a NormalizedTree -// goes through symbol_index_fill_module_tree and gets all three. +// bindings. That is a statement about the input, not a default: a caller holding NormalizedTrees +// goes through symbol_index_fill_module_roots, which projects them to ModuleBindingSource and gets +// all three. Single-root and whole-corpus are not symmetric doors here, and deliberately so: a +// binding row's target may be another root's declaration, so binding ONE root in isolation can only +// ever see what that root itself declares. fn symbol_index_fill_module_root(index: SymbolIndex, root: Node) -> SymbolIndex { match qualified_name_from_module_node(root: root) { Accepted { value: module_qn, diagnostics: _ } => diff --git a/src/v2/extdeps/languages/dag.dag b/src/v2/extdeps/languages/dag.dag index 4b08d6151db..2d50f9d22a8 100644 --- a/src/v2/extdeps/languages/dag.dag +++ b/src/v2/extdeps/languages/dag.dag @@ -5565,6 +5565,15 @@ fn alias_binding_row_target_from_decl(decl: Node, module_root: Node) -> Outcome< // a name added to m.p would silently start binding in S. Zero rows is not a silent drop -- the // reference that wanted the name is then off its own chain and refuses, located, at the reference // site, which is where section 13 puts the refusal. +// +// AN UNUSED IMPORT IS NO LONGER INERT, which is a real consequence of this transmutation and not a +// side note (review 69789 on gunbc#12009 found a stale one in this same change). Before it, an +// import block that named something the module never used was bookkeeping; now it MINTS A BINDING +// at the importing position and is a claimant like any other, so a stale name that collides with +// something else bound there is an ambiguity the resolver will refuse. That is the ruled model +// working as intended -- section 13 makes an alias an ordinary binding node and calls an unused one +// lintable dead code -- but it moves stale imports from harmless to load-bearing, and an author +// deleting a use must now delete its import row with it. // THE IMPORTED MODULE'S PATH, READ IN WHICHEVER FORM THE TREE IS IN. A parsed tree still carries // the grammar's capture for the qualified name; a normalized one carries the emitted edge // `dag_surface_import_decl_qualified_name` instead. Both are this row's authority for the same fact, From 894238a16ec47c1c81d5c842e4c4eec0668d5f99 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Tue, 22 Sep 2026 03:55:59 +0000 Subject: [PATCH 06/17] A binding is not a declaration, so it stays out of the spelling census quick-bat-813 asked whether #12009 changes what symbol_index_global_unique_lookup returns for a bare variant name, because gunbc#12033's pattern classifier reads it. It does, it was my defect, and it is fixed here. symbol_index_bind_at wrote through symbol_index_insert, which calls symbol_index_track_global_bare. That census answers ONE question -- how many DECLARATIONS spell this leaf -- and a transmuted import is not a declaration; it is a second PATH to one that already exists. track_global_bare cannot tell the difference on its own, because its uniqueness test demands `existing_path == qualified_path && existing == resolved`, so an import carrying the IDENTICAL declaration node under a different path flips the leaf to Ambiguous. MEASURED on the emitted route before the fix, not reasoned: with v2.acp_home declaring `AcDisposition` and v2.acp_user importing it, global_bare[AcDisposition] read AMBIGUOUS although exactly one module declares it. The oracle answering "two declarations" about one. WHY IT IS NOT HOUSEKEEPING. The oracle has a live reader. #12033's resolve_pattern_atom_names_constructor asks global_unique_lookup whether a bare atom in a match arm names a constructor and treats Ambiguous as YES, so every leaf this polluted would have pushed a fresh arm BINDER toward being read as a constructor and refused -- and my change widens that population to every imported name in the corpus. Writing a spelling census from a binding is the global-spelling-search defect wearing a different hat, which is the one thing this package exists to remove. New arm a_transmuted_import_does_not_make_its_leaf_globally_ambiguous, and it discriminates in BOTH directions, which is why it names two symbols. `NcrDisp` is declared once and imported once, so UNIQUE can only survive if the binding stayed out of the census. `NcrTwin` is genuinely declared by two modules, so it must stay AMBIGUOUS -- a "fix" that simply stopped writing the census would show up here as a false UNIQUE. Mutation control run: restoring the insert reds the new arm and nothing else. 9/9. Co-Authored-By: Claude Opus 5 (1M context) --- src/v2/std/symbol_index.dag | 21 +++++++- .../resolve/namespace_candidate_rule_test.dag | 52 +++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/src/v2/std/symbol_index.dag b/src/v2/std/symbol_index.dag index b3ac5ebca3a..140bda0eb51 100644 --- a/src/v2/std/symbol_index.dag +++ b/src/v2/std/symbol_index.dag @@ -246,6 +246,21 @@ fn symbol_index_claimants_at( } } +// THE GLOBAL-BARE ORACLE IS DELIBERATELY NOT WRITTEN HERE, and the reason is what the oracle +// MEANS: how many DECLARATIONS spell this leaf. A binding is not a declaration -- it is a second +// PATH to one that already exists -- so recording it there answers a different question with the +// same map. symbol_index_track_global_bare cannot tell the difference on its own: its uniqueness +// test demands `existing_path == qualified_path && existing == resolved`, so a transmuted import +// carrying the IDENTICAL declaration node under a different path flips the leaf to Ambiguous. +// +// MEASURED, on the emitted route: with `v2.acp_home` declaring `AcDisposition` and `v2.acp_user` +// importing it, global_bare[AcDisposition] read AMBIGUOUS although exactly one module declares it. +// That is the oracle answering "two declarations" about one. It matters beyond tidiness because +// the oracle has a live reader: gunbc#12033's pattern classifier asks global_unique_lookup whether +// a bare atom names a constructor, and treats Ambiguous as YES -- so every leaf this polluted +// would have pushed a fresh match-arm BINDER toward being read as a constructor and refused. +// Writing a spelling census from a binding is the global-spelling-search defect wearing a +// different hat, which is the one thing this package exists to remove. fn symbol_index_bind_at( index: SymbolIndex, binding_path: QualifiedName, @@ -266,7 +281,11 @@ fn symbol_index_bind_at( ) } if is_empty(xs: prior) { - symbol_index_insert(index: recorded, qualified_path: binding_path, resolved: resolved) + SymbolIndex { + entries: map_insert(m: recorded.entries, key: binding_path, value: resolved), + global_bare: recorded.global_bare, + bound_declarings: recorded.bound_declarings + } } else { recorded } diff --git a/src/v2/test/claim/resolve/namespace_candidate_rule_test.dag b/src/v2/test/claim/resolve/namespace_candidate_rule_test.dag index d1510167da8..53cceb03644 100644 --- a/src/v2/test/claim/resolve/namespace_candidate_rule_test.dag +++ b/src/v2/test/claim/resolve/namespace_candidate_rule_test.dag @@ -38,6 +38,13 @@ import v2.std.logic { Bool } import v2.std.node { Symbol } import v2.std.optional { Absent, Optional, Present, optional_absent, optional_present } import v2.std.qualified_name { QualifiedName, qualified_name_from_dotted_string } +import v2.std.symbol_index { + GlobalBareHit, + GlobalBareLookupAmbiguous, + GlobalBareLookupUnbound, + symbol_index_global_unique_lookup +} +import v2.compiler.name_resolve { ResolutionContext } import v2.std.text { String } data live_tree_disposition: LiveTreeDisposition = SubstrateInputsOnly @@ -390,6 +397,17 @@ fn ncr_verdicts_for(ingest: SourceRootIngest, module_name: String) -> Optional Optional { + match native_test_context_from_ingest(ingest: ncr_ingest) { + Rejected { diagnostics: _ } => optional_absent() + Accepted { value: ctx, diagnostics: _ } => + match ctx.resolution { + Rejected { diagnostics: _ } => optional_absent() + Accepted { value: rc, diagnostics: _ } => optional_present(value: rc) + } + } +} + fn ncr_outcomes() -> NcrOutcomes { match native_test_context_from_ingest(ingest: ncr_ingest) { Rejected { diagnostics: _ } => NcrContextRefused @@ -617,3 +635,37 @@ test fn a_qualified_reference_to_a_contested_position_refuses_naming_both_holds( && ncr_verdict_competing_count(v: qual) == 2 } } + +// A BINDING IS NOT A DECLARATION, AND THE SPELLING CENSUS MUST NOT COUNT IT AS ONE. The +// global-bare map is no longer a resolution mechanism -- section 13 leaves it as the migration +// oracle -- and the question it answers is how many DECLARATIONS spell a leaf. A transmuted import +// mints a second PATH to a declaration that already exists, so counting it would answer "two" +// about one module's type. +// +// THE ARM IS DISCRIMINATING BECAUSE OF WHICH NAME IT PICKS. `NcrDisp` is declared in exactly one +// module (v2.ncr_disp_home) and IMPORTED by exactly one other (v2.ncr_disp_user). Nothing else in +// the ingest spells it, so a UNIQUE answer here can only survive if the import binding stayed out +// of the census -- and before this was fixed the same reading came back AMBIGUOUS on the emitted +// route. `NcrTwin` is the companion control in the other direction: two modules really do declare +// it, so it must stay AMBIGUOUS and a fix that simply stopped writing the census would show up +// here as a false UNIQUE. +// +// IT IS NOT HOUSEKEEPING: gunbc#12033's pattern classifier reads this oracle to decide whether a +// bare atom in a match arm names a constructor, and reads Ambiguous as yes. Every leaf polluted +// this way would push a fresh arm BINDER toward being classified as a constructor and refused. +test fn a_transmuted_import_does_not_make_its_leaf_globally_ambiguous_holds() -> Bool { + match ncr_context() { + Absent => false + Present { value: rc } => + (match symbol_index_global_unique_lookup(index: rc.symbol_index, name: ^NcrDisp) { + GlobalBareHit { node: _, path: _ } => true + GlobalBareLookupAmbiguous => false + GlobalBareLookupUnbound => false + }) + && (match symbol_index_global_unique_lookup(index: rc.symbol_index, name: ^NcrTwin) { + GlobalBareHit { node: _, path: _ } => false + GlobalBareLookupAmbiguous => true + GlobalBareLookupUnbound => false + }) + } +} From 5aee53703325f72928210ef7a0f013e82f601786 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Tue, 22 Sep 2026 04:30:16 +0000 Subject: [PATCH 07/17] Delete the annotation fragment my own deletion orphaned Review 69926, second finding. When symbol_index_fill_module_bindings and symbol_index_fill_module_tree were deleted as dangling, the deletion cut their "PASS B" annotation mid-sentence -- "...an `alias` decl is grafted into the tree as an ordinary" -- and spliced the remainder directly onto the next, unrelated "THE NODE-ONLY DOOR" block. So an annotation describing a function that no longer exists was sitting above a function it half-described. The fragment is removed; the NODE-ONLY DOOR annotation was already complete and correct on its own. Nothing about the surviving text needed changing: the two-pass story now lives on symbol_index_fill_module_roots, where the fixed point is. Co-Authored-By: Claude Opus 5 (1M context) --- src/v2/compiler/symbol_index_fill.dag | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/v2/compiler/symbol_index_fill.dag b/src/v2/compiler/symbol_index_fill.dag index 77ea428caf4..4ee8e36dc6f 100644 --- a/src/v2/compiler/symbol_index_fill.dag +++ b/src/v2/compiler/symbol_index_fill.dag @@ -374,9 +374,6 @@ fn symbol_index_fill_module_declarations(index: SymbolIndex, root: Node) -> Symb } } -// PASS B -- WHAT THIS ROOT BINDS FROM ELSEWHERE. Every row here reads ANOTHER root's declarations -// out of the index, so it can only be run once every root's pass A has been. The two sources of -// rows differ only in where they SURVIVED: an `alias` decl is grafted into the tree as an ordinary // THE NODE-ONLY DOOR, AND ITS NAME SAYS WHAT IT CANNOT DO. A caller holding only a grafted Node has // no import rows to offer -- the graft dissolved them and they are not recoverable from what is // left -- so this fills declarations and the alias rows still present in the tree, and no import From b5023bb063cf5d27f42eb2ea35627372d6f55e56 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Tue, 22 Sep 2026 04:40:01 +0000 Subject: [PATCH 08/17] Resolution carries the declaring path to its consumers, so a resolved reference is no longer a spelling resolve computed the declaring path of a reference and discarded it one line later, minting canonical_atom(identity: canonical) where canonical was a spelling. resolved_reference_identity now decides ResolvedToKernelSymbol | ResolvedToDeclaration{path}, and resolved_reference_node is the single producer both the bare and qualified doors mint through. The interim refusal qualified_target_identity_unrepresentable is retired as an accept. Carrier is the existing qualified-name spine; no new identity vocabulary. translate projects it through TargetTypeExpressionProjection declaration_reference_form (rust: ModuleScopedPath; typescript: refuses, located). infer and eval take typed not-derived / typed miss arms pending their own consumption of the path. Co-Authored-By: Claude Opus 5 (1M context) --- ...al_reason_minted_as_canonical_identity.dag | 2 + dag/std/occurrence_binding.dag | 12 + src/v2/compiler/03_resolve.dag | 218 +++++++++--------- src/v2/compiler/04_infer.dag | 13 ++ src/v2/compiler/05_eval.dag | 18 ++ src/v2/compiler/06_translate.dag | 25 ++ src/v2/compiler/body_lowering_fold.dag | 12 +- src/v2/compiler/resolution_provenance.dag | 31 ++- src/v2/extdeps/languages/rust.dag | 21 +- src/v2/extdeps/languages/typescript.dag | 4 +- src/v2/std/compilers/target_model.dag | 207 ++++++++++++++++- src/v2/std/qualified_name.dag | 45 +++- .../sg2_type_expression_projection_test.dag | 3 +- ...cross_module_reference_resolution_test.dag | 185 +++++++++------ .../declaration_reference_form_test.dag | 155 +++++++++++++ .../workflow/compile_door_cause_ownership.dag | 6 - 16 files changed, 756 insertions(+), 201 deletions(-) create mode 100644 src/v2/test/claim/translate/declaration_reference_form_test.dag diff --git a/dag/gunbc/recurring_failure_mode/refusal_reason_minted_as_canonical_identity.dag b/dag/gunbc/recurring_failure_mode/refusal_reason_minted_as_canonical_identity.dag index aab58c1cbf3..64afe256619 100644 --- a/dag/gunbc/recurring_failure_mode/refusal_reason_minted_as_canonical_identity.dag +++ b/dag/gunbc/recurring_failure_mode/refusal_reason_minted_as_canonical_identity.dag @@ -14,8 +14,10 @@ data refusal_reason_minted_as_canonical_identity: RecurringFailureMode = Recurri "RUNG FOUND AT: outside the ladder -- silent wrongness.", "CEILING: structurally impossible. A reason symbol and a canonical identity are different concepts and should not share a carrier; a resolver that returns Symbol for both can write one where the other is owed. The wall is the type: resolution answering with a declaration identity carrier that a Diagnostic.reason cannot inhabit.", "NEXT-RUNG TRIGGER: v2.compiler.resolve canonical identities carried as a declaration-identity type distinct from Symbol, so that the fallback argument cannot be spelled with a reason symbol; until then the enrolled row above is the wall.", + "CLIMB RECEIPT (2026-09-22, fierce-wren-487). The trigger fired: v2.compiler.resolve now decides ResolvedReferenceIdentity = ResolvedToKernelSymbol { symbol } | ResolvedToDeclaration { path } and mints every module-level reference through resolved_reference_node, so a corpus declaration reference is carried as its declaring QualifiedName -- the qualified-name spine -- and the `fallback:` argument, symbol_index_node_identity, and the interim refusal resolve_reason_qualified_target_identity_unrepresentable are deleted. A reason symbol can no longer reach the declaration arm (a path is not a Symbol), and the kernel arm admits only a symbol the language model declares canonical, which no reason symbol is. Rung: structurally guaranteed, not impossible -- an Atom naming a corpus declaration remains constructible by a fixture; no Accepted resolved body carries one because resolve is the only producer and its remaining canonical_atom sites are the frame-local binder, the literal and the kernel arm. Evidence, by execution: v2.test.claim.namespace_xl0.cross_module_reference_resolution -- two consumers importing one leaf from two providers resolved to EQUAL nodes before this change with no refusal at all (the silent collapse the ruling predicted), and now resolve to their two declaring paths; the found-fn and data-target rows flipped from the interim refusal to Accepted carrying the path.", ], evidence: [ DeclarationRef { module_path: "v2.compiler.resolve", decl_name: "try_resolve_qualified_name_node", field: WholeDeclaration }, + DeclarationRef { module_path: "v2.compiler.resolve", decl_name: "resolved_reference_node", field: WholeDeclaration }, ], } diff --git a/dag/std/occurrence_binding.dag b/dag/std/occurrence_binding.dag index 8e915ad752f..90a3ee1b799 100644 --- a/dag/std/occurrence_binding.dag +++ b/dag/std/occurrence_binding.dag @@ -11,6 +11,18 @@ import std.types { String } // by the v2 resolver instantiation and its DependencyView BindsTo projection. LexicalLookup is not // an interim consumer: it has no exact reference-occurrence Node or occurrence containment // identity, so binding it here would fabricate the relation this carrier preserves. +// +// WHAT THE V2 INSTANTIATION IS, READ FROM THE OTHER SIDE (fierce-wren-487, 2026-09-22, ruled a +// declared divergence and not a fork). v2.std.symbol_index LexicalLookup and v2.compiler.resolve +// SymbolIndexAtomLookup answer the SAME question this result type answers -- which declaration +// binds one reference occurrence: none, one, or more than one -- at the same grain (v2's hit is +// for one occurrence and carries its occurrence id), and v2's 0/1/many selection over its +// candidates (symbol_index_lexical_lookup) duplicates occurrence_binding_from_candidates. The two +// naming schemes agree: ContainmentPath of nodes and v2's QualifiedName of Named edges are the +// same containment path read as nodes vs as labels, which is why v2 resolution now carries a +// declaration reference as that path. The candidate PRODUCERS are legitimately layered (a supplied +// population here, the index chain walk there) and are not what the trigger collapses; the +// trigger is v2's selection fold and result vocabulary retiring into OccurrenceBindingResult. type ContainmentPath { ancestors: FreeMonoid diff --git a/src/v2/compiler/03_resolve.dag b/src/v2/compiler/03_resolve.dag index c62ebaf5dc5..85971d220a6 100644 --- a/src/v2/compiler/03_resolve.dag +++ b/src/v2/compiler/03_resolve.dag @@ -56,7 +56,13 @@ import v2.std.diagnostic { diagnostics_singleton, rejected_with_pending } -import v2.std.qualified_name { QualifiedName, qualified_name_from_node, qualified_name_snoc } +import v2.std.qualified_name { + QualifiedName, + qualified_name_from_node, + qualified_name_last_segment, + qualified_name_snoc, + qualified_name_spine_node +} import v2.std.resolution_policy { ImportScoped, NameResolutionPolicy, @@ -70,8 +76,8 @@ import v2.std.symbol_index { LexicalUnbound, SymbolIndex, empty_symbol_index, - symbol_index_lexical_lookup, - symbol_index_lookup + symbol_index_absolute_candidates, + symbol_index_lexical_lookup } import v2.std.node { Arrow, @@ -398,21 +404,6 @@ fn unbound_symbol_diagnostic(n: Node) -> Diagnostic { } } -// A QUALIFIED HIT ON A NON-ATOM DECLARATION HAS NO IDENTITY THIS RESOLVER CAN CARRY, SO IT REFUSES. -// The resolved value here is a canonical Atom -- one Symbol -- and a fn (an Arrow) or a record -// found by its full path is a DECLARATION, not a symbol: collapsing it to its leaf name would make -// `a.run` and `b.run` indistinguishable downstream, which is an identity fork the import cut's -// replacement graph cannot be built on. Until resolution answers with an exact declaring-identity -// carrier (the unique-on-chain lane's changeover), the hit is refused with this located reason -// rather than minted from a leaf. Enrolled: v2.test.claim.namespace_xl0.cross_module_reference_resolution. -fn qualified_target_identity_unrepresentable_diagnostic(n: Node) -> Diagnostic { - Diagnostic { - reason: ^resolve_reason_qualified_target_identity_unrepresentable, - at: node_locus(node: n), - correction: Unavailable { reason: UserInputBoundary } - } -} - fn float_literal_not_lowered_diagnostic(n: Node) -> Diagnostic { Diagnostic { reason: ^float_literal_not_lowered, @@ -533,15 +524,50 @@ fn symbol_index_lookup_position(ctx: ResolveContext) -> QualifiedName { } } -fn symbol_index_node_identity(node: Node, fallback: Symbol) -> Symbol { - match node.kind { - TypeNode { connective: Atom { identity: sym } } => sym - _ => fallback +// WHAT A RESOLVED REFERENCE IS, DECIDED ONCE. A reference that binds to a corpus declaration is +// carried as that declaration's containment path -- the identity the index key, the candidate +// rows, DeclarationLocus and DeclarationRef already name -- and never as its leaf: two declarations +// spelled `run` in two modules are two paths, and a leaf would collapse them silently (the class +// gunbc.recurring_failure_mode refusal_reason_minted_as_canonical_identity records, whose interim +// refusal resolve_reason_qualified_target_identity_unrepresentable stood where this carrier now +// stands). A reference whose LEAF is a kernel canonical symbol resolves to the kernel atom, as it did +// before this carrier: the language model owns those names, they are not free for the corpus to +// re-declare, and every downstream kernel judgment (infer's Bool and Int checks, translate's +// binding spellings) is keyed on the canonical symbol. That arm is the whole of what stays +// spelling-keyed here, on a closed set the language model declares; a corpus twin of a kernel name +// is a pre-existing fork beside this seam, not one this decision widens. +type ResolvedReferenceIdentity + = ResolvedToKernelSymbol { symbol: Symbol } + | ResolvedToDeclaration { path: QualifiedName } + +fn resolved_reference_identity(ctx: ResolveContext, path: QualifiedName) -> ResolvedReferenceIdentity { + match qualified_name_last_segment(qn: path) { + Present { value: leaf } => + if namespace_has_canonical_symbol(namespace: ctx.namespace, name: leaf) { + ResolvedToKernelSymbol { symbol: leaf } + } else { + ResolvedToDeclaration { path: path } + } + Absent => ResolvedToDeclaration { path: path } + } +} + +// THE ONE PRODUCER OF A RESOLVED CORPUS REFERENCE. Both doors that bind a module-level name -- the +// bare door (resolve_atom_bound) and the qualified door (try_resolve_qualified_name_node) -- mint +// through here, so the carrier cannot differ by how the reference was spelled. The rung this buys +// (DESIGN section 4b) is structurally guaranteed, not impossible: an Atom naming a corpus +// declaration is still constructible by a fixture or a hand-built node, but no Accepted resolved +// body contains one, because this module is the only producer of resolved bodies and its remaining +// canonical_atom sites are the frame-local binder, the literal, and the kernel arm above. +fn resolved_reference_node(identity: ResolvedReferenceIdentity, occurrence_id: NodeOccurrenceIdentity) -> Node { + match identity { + ResolvedToKernelSymbol { symbol: symbol } => canonical_atom(identity: symbol, occurrence_id: occurrence_id) + ResolvedToDeclaration { path: path } => qualified_name_spine_node(qn: path, occurrence_id: occurrence_id) } } type SymbolIndexAtomLookup - = SymbolIndexAtomHit { canonical: Symbol, path: QualifiedName } + = SymbolIndexAtomHit { path: QualifiedName } | SymbolIndexAtomAmbiguous { class: AmbiguousLookupClass } | SymbolIndexAtomUnbound @@ -572,11 +598,7 @@ fn lookup_symbol_index_atom_identity( position: symbol_index_lookup_position(ctx: ctx), name: name ) { - LexicalHit { node: node, path: path } => - SymbolIndexAtomHit { - canonical: symbol_index_node_identity(node: node, fallback: name), - path: path - } + LexicalHit { node: _, path: path } => SymbolIndexAtomHit { path: path } LexicalAmbiguous { candidates: candidates } => SymbolIndexAtomAmbiguous { class: AmbiguousOnLexicalChain { candidates: candidates } } LexicalUnbound => SymbolIndexAtomUnbound @@ -592,9 +614,13 @@ fn lookup_symbol_index_atom_identity( // began to (see body_lower_dotted_reference_optional). One revision of gunbc#11582 replaced the // reason with the path's last segment; the side-chat ruling on that head found the leaf equally // fabricated -- two declarations named `run` in two modules collapse to one Atom -- so a hit whose -// declaration is not itself an Atom now REFUSES (qualified_target_identity_unrepresentable_diagnostic) -// until resolution carries exact declaring identity. Measured: a cross-module reference to a fn -// came back Accepted with identity resolve_reason_unbound_symbol before the first repair. +// declaration was not itself an Atom REFUSED (resolve_reason_qualified_target_identity_unrepresentable) +// until resolution carried exact declaring identity. It now does: the hit is minted through +// resolved_reference_node as the declaration's path, the same producer the bare door uses, and the +// interim refusal is deleted with nothing left for it to guard -- every index hit IS a path, so a +// control for a refusal with no authorable red would be a decoration (DESIGN section 4b). Measured: +// a cross-module reference to a fn came back Accepted with identity resolve_reason_unbound_symbol +// before the first repair. // THE HALF OF UNIQUE-ON-CHAIN THAT CAN BE HONEST BEFORE THE PROJECTION EXISTS (review 67651 on // gunbc#11582). Section 13 resolves a chain's FIRST segment on the ancestor chain and projects the // rest; the projection is unfired here, and this arm reads the whole path as corpus-root @@ -638,80 +664,56 @@ fn try_resolve_qualified_name_node(ctx: ResolveContext, n: Node) -> Optional optional_present(value: Rejected { diagnostics: rejected_with_pending(pending: pending, rejected: r) }) Accepted { value: head_bound, diagnostics: chain_pending } => - if length(xs: symbol_index_absolute_candidates(index: ctx.namespace.symbol_index, qualified_path: path)) > 1 { - 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: symbol_index_absolute_candidates( - index: ctx.namespace.symbol_index, - qualified_path: path - ) - } + match symbol_index_absolute_candidates(index: ctx.namespace.symbol_index, qualified_path: path) { + Empty => + 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)) ) - ) - } - ) - } else { - match symbol_index_lookup(index: ctx.namespace.symbol_index, qualified_path: path) { - Present { value: resolved_node } => - if head_bound { - optional_present( - value: Rejected { - diagnostics: rejected_with_pending( - pending: diagnostics_merge(outer: pending, inner: chain_pending), - rejected: ambiguous_symbol_diagnostics( + } + ) + 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, - class: AmbiguousQualifiedHeadShadowsAbsolute { path: path } + path: candidate.path, + pending: diagnostics_merge(outer: pending, inner: chain_pending) ) ) } - ) - } else { - match test_code_reach(index: ctx.namespace.test_code, path: path) { - ReachesTestMarkedDeclaration => - optional_present( - value: Rejected { - diagnostics: rejected_with_pending( - pending: diagnostics_merge(outer: pending, inner: chain_pending), - rejected: diagnostics_singleton(d: test_code_referenced_diagnostic(reason: ^resolve_reason_test_code_referenced, n: n)) - ) - } - ) - ReachesNoTestCode => - match resolved_node.kind { - TypeNode { connective: Atom { identity: declared } } => - optional_present( - value: Accepted { - value: canonical_atom(identity: declared, occurrence_id: n.occurrence_id), - diagnostics: 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: diagnostics_singleton(d: qualified_target_identity_unrepresentable_diagnostic(n: n)) + rejected: ambiguous_symbol_diagnostics( + n: n, + class: AmbiguousAtBindingPosition { + candidates: Cons { head: candidate, tail: rest } + } + ) ) } ) } - } - } - Absent => - 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)) - ) - } - ) - } } } } else { @@ -738,26 +740,31 @@ fn test_code_referenced_diagnostic(reason: Symbol, n: Node) -> Diagnostic { // THE WALL (owner ruling 2026-09-16/17): `test` segregates test code from serving code, and no code // may reference a test-marked declaration, a test fn in the same module included. It is decided here, // where the reference binds, from the path it bound to, and it refuses with the reference's own locus. -// TWO ENFORCEMENT SITES, ONE DECISION. This function is reached by the doors whose accepted result is -// a canonical atom -- the root scope and the symbol index. The qualified-path door -// (try_resolve_qualified_name_node) cannot route through it, because its accepted arm must first read -// the resolved node's kind and carry the chain's own pending diagnostics, so it refuses inline -// instead. Both sites decide from ONE authority, test_code_reach over ctx.namespace.test_code, and -// raise the same reason through the same test_code_referenced_diagnostic: a change to the RULE is a -// change to that fold, not to either site. What a later door must not do is bind a module-level name -// without asking test_code_reach at all -- the per-door reds in +// ONE ENFORCEMENT SITE, ONE DECISION. Every door that binds a module-level name -- the root scope, +// the symbol index, and the qualified-path door (try_resolve_qualified_name_node) -- accepts through +// this function, which asks test_code_reach over ctx.namespace.test_code and then mints the resolved +// reference through the one producer, resolved_reference_node, from the DECLARING path the door +// selected. The qualified door used to refuse inline because its accepted arm had to read the +// resolved node's kind first; the carrier no longer depends on the kind, so that second site is +// gone. The path a door hands in is the declaration's own (the candidate rows name claimants by +// declaring path, v2.std.symbol_index symbol_index_candidates_at), never the binding position it +// was seen at -- an import or alias position would have minted one declaration into as many +// identities as it has importers. What a later door must not do is bind a module-level name +// without passing through here -- the per-door reds in // v2.test.claim.name_resolve.test_code_reference_wall are what hold that, one per door. fn resolve_atom_bound( ctx: ResolveContext, n: Node, - canonical: Symbol, path: QualifiedName, pending: Diagnostics ) -> Outcome { match test_code_reach(index: ctx.namespace.test_code, path: path) { ReachesNoTestCode => Accepted { - value: canonical_atom(identity: canonical, occurrence_id: n.occurrence_id), + value: resolved_reference_node( + identity: resolved_reference_identity(ctx: ctx, path: path), + occurrence_id: n.occurrence_id + ), diagnostics: pending } ReachesTestMarkedDeclaration => @@ -785,18 +792,17 @@ fn resolve_atom(ctx: ResolveContext, n: Node, identity: Symbol) -> Outcome value: canonical_atom(identity: canonical, occurrence_id: n.occurrence_id), diagnostics: pending } - BoundAtRoot { canonical: canonical } => + BoundAtRoot { canonical: _ } => resolve_atom_bound( ctx: ctx, n: n, - canonical: canonical, path: qualified_name_snoc(qn: root_binding_origin(namespace: ctx.namespace, name: identity), segment: identity), pending: pending ) ScopeUnbound => match lookup_symbol_index_atom_identity(ctx: ctx, name: identity) { - SymbolIndexAtomHit { canonical: canonical, path: path } => - resolve_atom_bound(ctx: ctx, n: n, canonical: canonical, path: path, pending: pending) + SymbolIndexAtomHit { path: path } => + resolve_atom_bound(ctx: ctx, n: n, path: path, pending: pending) SymbolIndexAtomAmbiguous { class: class } => Rejected { diagnostics: rejected_with_pending( diff --git a/src/v2/compiler/04_infer.dag b/src/v2/compiler/04_infer.dag index 4d718e7fe99..8eadc689a99 100644 --- a/src/v2/compiler/04_infer.dag +++ b/src/v2/compiler/04_infer.dag @@ -43,6 +43,7 @@ import v2.std.optional { optional_absent, optional_present } +import v2.std.qualified_name { declaration_reference_path_optional } import v2.std.constraints { CanonicalGrounding, CanonicalGroundingWitness, @@ -2161,6 +2162,14 @@ fn infer_gather_fold_not_derived(n: Node, partials: List, tree: Node) -> I ) } +// THE CONJ ARM READS A RESOLVED CORPUS-DECLARATION REFERENCE FIRST, BECAUSE IT IS CONJ-SHAPED AND +// NOT A RECORD. Resolution carries a reference to a declaration as its declaring path +// (v2.compiler.resolve resolved_reference_node), a qualified-name spine that shares the Conj +// connective with a product; read as a product it would be typed as a two-field record of its own +// segments, silently. It is exactly what a bare Atom reference was to this fold before the carrier +// -- a grounding this fold does not derive -- and it takes that arm. Deriving the reference's type +// FROM its declaration by path is the end-state the roster lookups above already name as their +// dissolution, not this arm. fn infer_gather_fold_init(n: Node, partials: List, tree: Node) -> InferGatherFoldAcc { match n.kind { ComputationNode { behavior: Branch } => @@ -2221,6 +2230,9 @@ fn infer_gather_fold_init(n: Node, partials: List, tree: Node) -> InferGat ComputationNode { behavior: Bind } => infer_gather_fold_not_derived(n: n, partials: partials, tree: tree) TypeNode { connective: Atom { identity: _ } } => infer_gather_fold_not_derived(n: n, partials: partials, tree: tree) TypeNode { connective: Conj } => + match declaration_reference_path_optional(node: n) { + Present { value: _ } => infer_gather_fold_not_derived(n: n, partials: partials, tree: tree) + Absent => if infer_type_node_awaits_product_row(n: n) { infer_gather_fold_acc_ok( node: n, @@ -2235,6 +2247,7 @@ fn infer_gather_fold_init(n: Node, partials: List, tree: Node) -> InferGat } else { infer_gather_fold_not_derived(n: n, partials: partials, tree: tree) } + } TypeNode { connective: Disj } => infer_gather_fold_not_derived(n: n, partials: partials, tree: tree) TypeNode { connective: Arrow } => if infer_type_node_awaits_product_row(n: n) { diff --git a/src/v2/compiler/05_eval.dag b/src/v2/compiler/05_eval.dag index 91a0781aa68..0e05426d582 100644 --- a/src/v2/compiler/05_eval.dag +++ b/src/v2/compiler/05_eval.dag @@ -23,6 +23,7 @@ import v2.std.collection { list_at_optional, list_nth } +import v2.std.qualified_name { declaration_reference_path_optional } import v2.std.optional { Absent, Optional, @@ -1235,11 +1236,27 @@ fn eval_runtime_binding_lookup(node: Node, environment: EvaluationEnvironment) - ) } +// A RESOLVED CORPUS-DECLARATION REFERENCE IS REFUSED HERE AS A BINDING MISS, NEVER ALLOCATED. +// Resolution carries a reference to a declaration as its declaring path (v2.compiler.resolve +// resolved_reference_node), a Conj-shaped spine; the default TypeNode arm below allocates a literal +// from any non-Atom type node, which for the spine would fabricate a two-field record of the +// reference's own segments. The evaluator binds frame-local names and parameters by symbol and +// binds no module-level declaration at all (a callee that is not an Arrow already falls to the +// primitive table and refuses there), so a declaration reference in value position is what a bare +// Atom miss was before the carrier: unbound in this environment, refused with the miss reason at +// the reference's own locus. Binding module-level declarations by their path is the evaluator's +// own next step and is not taken here. fn eval_type_node_atom( node: Node, interpretation: InterpretationAlgebra, environment: EvaluationEnvironment ) -> Outcome { + match declaration_reference_path_optional(node: node) { + Present { value: _ } => + outcome_rejected( + d: eval_diagnostic(reason: ^eval_rejected_runtime_binding_lookup_miss, node: node) + ) + Absent => match node.kind { TypeNode { connective: Atom { identity: _ } } => if v2_eval_is_literal_shaped_node(node: node) { @@ -1262,6 +1279,7 @@ fn eval_type_node_atom( ComputationNode { behavior: _ } => outcome_rejected(d: eval_diagnostic(reason: ^eval_rejected_type_node, node: node)) } + } } type EvalParamBindAcc { diff --git a/src/v2/compiler/06_translate.dag b/src/v2/compiler/06_translate.dag index 47ea7459b6f..cff0b14d529 100644 --- a/src/v2/compiler/06_translate.dag +++ b/src/v2/compiler/06_translate.dag @@ -134,6 +134,7 @@ import v2.std.compilers.target_model { TargetTypeExprArrowLabeledWire, TargetTypeExprArrowPositionalWire, target_type_expr_atom_emitted, + target_type_expr_declaration_reference_emitted, target_type_expr_cardinality_emitted, target_type_expr_emitted_labeled_slot_edges, target_type_expr_emitted_labeled_slot_nodes, @@ -202,6 +203,7 @@ import v2.std.collection { list_at_optional, map_get } +import v2.std.qualified_name { declaration_reference_path_optional } import v2.std.optional { Absent, Optional, @@ -1986,12 +1988,34 @@ fn translate_type_expression_arrow_project( ) } +// THE FOLD READS A RESOLVED CORPUS-DECLARATION REFERENCE BEFORE IT READS THE CONNECTIVE. Resolution +// carries a reference to a declaration as its declaring path (v2.compiler.resolve +// resolved_reference_node), a qualified-name spine that shares the Conj connective with a record; +// folded by connective it would become TypeExprTranslateSlots and be emitted as a two-field record +// of its own segments. So the spine is decided first and emitted through the target's declaration +// reference form (v2.std.compilers.target_model target_type_expr_declaration_reference_emitted): +// the derived module-scoped spelling where the target has one, a located refusal where it has none +// -- never the leaf, which is the collapse resolution refused to make. The spine is DONE at init, +// so its segment children are never folded as slots. fn translate_algebra( target: TargetModel, projection: TargetTypeExpressionProjection ) -> NodeFold> { NodeFold { init: fn(n) { + match declaration_reference_path_optional(node: n) { + Present { value: path } => + bind_outcome( + o: target_type_expr_declaration_reference_emitted( + projection: projection, + path: path, + reference: n + ), + f: fn(emitted) { + Accepted { value: TypeExprTranslateDone { node: emitted }, diagnostics: None } + } + ) + Absent => match n.kind { TypeNode { connective: connective } => match connective { @@ -2040,6 +2064,7 @@ fn translate_algebra( ComputationNode { behavior: _ } => outcome_rejected(translate_type_expression_shape_missing_diagnostic(node: n)) } + } }, step: fn(acc, e, child) { match acc { diff --git a/src/v2/compiler/body_lowering_fold.dag b/src/v2/compiler/body_lowering_fold.dag index 81a9e1b5196..216ad86b267 100644 --- a/src/v2/compiler/body_lowering_fold.dag +++ b/src/v2/compiler/body_lowering_fold.dag @@ -37,7 +37,7 @@ import v2.extdeps.languages.dag { parse_qualified_name_segments_from_capture, qualified_name_from_module_node, } -import v2.std.algebra { fold_list, fold_list_node, list_append, list_snoc_item, length, zip_eq } +import v2.std.algebra { fold_list, list_append, list_snoc_item, length, zip_eq } import v2.std.collection { List, list_at_optional @@ -71,7 +71,7 @@ import v2.std.diagnostic { rejected_with_pending } import v2.std.integer { Int, integer_string_to_decimal_digits_optional } -import v2.std.qualified_name { QualifiedName, qualified_name_snoc, qualified_name_spine_shape_present } +import v2.std.qualified_name { QualifiedName, qualified_name_snoc, qualified_name_spine_node, qualified_name_spine_shape_present } import v2.std.symbol_index { SymbolIndex, empty_symbol_index, @@ -2763,13 +2763,7 @@ fn body_lower_dotted_chain_tail_read( } fn body_lower_qualified_name_spine_node(segments: List, source: Node) -> Node { - let spine = fold_list_node( - xs: segments, - item_node: fn(sym) { - node_synthetic(kind: TypeNode { connective: Atom { identity: sym } }, children: []) - } - ) - node_with_occurrence_id(kind: spine.kind, children: spine.children, occurrence_id: source.occurrence_id) + qualified_name_spine_node(qn: segments, occurrence_id: source.occurrence_id) } fn body_lower_dotted_chain_read(node: Node) -> DottedChainTailRead { diff --git a/src/v2/compiler/resolution_provenance.dag b/src/v2/compiler/resolution_provenance.dag index f4b3f59ed4a..88b3e675d9b 100644 --- a/src/v2/compiler/resolution_provenance.dag +++ b/src/v2/compiler/resolution_provenance.dag @@ -1,6 +1,8 @@ module v2.compiler.resolution_provenance import std.algebra { Empty, FreeMonoid } +import std.dissolution { DissolutionCondition, unbound_dissolution } +import std.types { NonEmptyStr } import v2.std.algebra { contains, fold_list, @@ -37,13 +39,16 @@ import v2.std.symbol_index { } import v2.extdeps.languages.dag { qualified_name_from_module_node } -// Resolution already knows which declaration answered a name but throws the answer's PATH away -- -// lookup_symbol_index_atom_identity keeps only the canonical Symbol, so nothing downstream can say -// which module supplied a binding. That is why dependency edges are currently matched from the -// reference SPELLING against declared module paths, a syntactic guess DESIGN section 4 rules out. -// This module keeps the path: one name resolved at one position answers with its target and the -// module that CONTAINS it, and the dependency derivation below consumes those answers and nothing -// else. +// THIS MODULE IS A SECOND RESOLVER, AND IT IS NOW DELETABLE. It was written because resolution knew +// which declaration answered a name and threw the answer's PATH away -- lookup_symbol_index_atom_identity +// kept only a canonical Symbol, so nothing downstream could say which module supplied a binding, and +// dependency edges were matched from the reference SPELLING against declared module paths, a +// syntactic guess DESIGN section 4 rules out. So this module re-ran the lookup and kept the path. +// Since fierce-wren-487 (2026-09-22) resolution itself carries the path: v2.compiler.resolve +// resolved_reference_node mints every corpus declaration reference as its declaring QualifiedName +// (a qualified-name spine, read back by v2.std.qualified_name declaration_reference_path_optional). +// One fact, two resolvers, is the fork DESIGN section 3 names; this module's answers are the second. +// It is kept, frozen, until its consumers read the resolved tree -- the row below is the trigger. type ReferenceSite { position: QualifiedName @@ -302,3 +307,15 @@ fn answer_target_optional(answer: ResolutionProvenance) -> Optional optional_absent() } } + +// DISSOLVE-ON, AT CAPABILITY GRAIN: when every consumer of ResolutionProvenance -- the dependency +// derivation in this module, v2.compiler.repair_input_origin_roster (whose own frontier row, +// repair_input_declared_binding_frontier_dissolve_on, names the same capability) and +// v2.compiler.declaring_identity_spelling_census -- derives the declaring path of a mention from the +// RESOLVED TREE the production resolver emits (v2.std.qualified_name declaration_reference_path_optional +// over v2.compiler.resolve output) rather than from resolve_reference_provenance re-running the +// lookup, this module is DELETED together with this row. What makes it deletable is already true: +// the resolved tree carries the path. What has not happened is the consumers reading it; a carrier +// nothing consumes would satisfy a merge-shaped trigger while the second resolver stayed the only +// thing answering (DESIGN section 4b(3)), so the trigger is the consumers' route, by execution. +data resolution_provenance_second_resolver_dissolve_on: DissolutionCondition = unbound_dissolution(description: "DISSOLVE-ON: every consumer of ResolutionProvenance derives a mention's declaring path from the resolved tree (v2.std.qualified_name declaration_reference_path_optional over v2.compiler.resolve output) by execution; then delete this module and this row." as NonEmptyStr) diff --git a/src/v2/extdeps/languages/rust.dag b/src/v2/extdeps/languages/rust.dag index 27e6beabb40..75d562d0169 100644 --- a/src/v2/extdeps/languages/rust.dag +++ b/src/v2/extdeps/languages/rust.dag @@ -198,6 +198,8 @@ import v2.std.compilers.target_model { ValueProducing, derive_bodied_arrow_scaffold, TargetAtomRealization, + DeclarationReferenceForm, + ModuleScopedPath, TargetAtomTypeShape, TargetTypeExpression, target_atom_alias_type_expression, @@ -4351,7 +4353,24 @@ fn rust_type_expression_projection() -> TargetTypeExpressionProjection { separator: ^rust_token_comma, field_label_separator: optional_absent(), close: ^rust_token_gt - } + }, + declaration_reference_form: rust_declaration_reference_form() + } +} + +// A CORPUS DECLARATION REFERENCE IS SPELLED AS A CRATE-ROOTED PATH: `crate::::`, with +// the .dag module's dotted segments joined by `_` into one Rust module identifier. `crate` and `::` +// are Rust's (The Rust Reference, Paths: `crate` names the crate root, `::` is the path separator); +// the `_` joining is the layout the seed's emission realizes one Rust module per .dag module +// under (v1.compiler.emit_core_support module_to_filename), stated here so that a reference this +// projection spells lands on the same module the seed emits the declaration into. Always crate-rooted +// rather than bare-when-same-module: a fully qualified path is valid at every use site, so the +// spelling carries no dependence on where the reference sits. +fn rust_declaration_reference_form() -> DeclarationReferenceForm { + ModuleScopedPath { + root_text: "crate", + scope_separator_text: "::", + module_segment_joiner: "_" } } data rust_sg2_type_alias_text: String = "type Sg2Probe = Rc>;" diff --git a/src/v2/extdeps/languages/typescript.dag b/src/v2/extdeps/languages/typescript.dag index aa9639a8532..862ee71a6bb 100644 --- a/src/v2/extdeps/languages/typescript.dag +++ b/src/v2/extdeps/languages/typescript.dag @@ -102,6 +102,7 @@ import v2.std.compilers.target_model { ProducedDeclUnwired, ConcreteSyntaxToken, FixedToken, BoundToken, concrete_syntax_token_class, concrete_syntax_token_to_node } import v2.std.compilers.target_model { TargetModel, target_model_emit_transforms_empty } import v2.std.compilers.target_model { + NoDeclarationReferenceForm, TargetAtomRealization, TargetAtomTypeShape, TargetFunctionTypeShape, @@ -1654,7 +1655,8 @@ fn ts_type_expression_projection() -> TargetTypeExpressionProjection { separator: ^ts_token_comma, field_label_separator: optional_absent(), close: ^ts_token_gt - } + }, + declaration_reference_form: NoDeclarationReferenceForm } } diff --git a/src/v2/std/compilers/target_model.dag b/src/v2/std/compilers/target_model.dag index 7ca9ff17fd5..236bbebb4ee 100644 --- a/src/v2/std/compilers/target_model.dag +++ b/src/v2/std/compilers/target_model.dag @@ -48,6 +48,7 @@ import v2.std.optional { } import v2.std.diagnostic { Accepted, + CorrectionNotModeled, Diagnostic, Diagnostics, ExternalContractUnknown, @@ -139,7 +140,9 @@ import v2.std.node_query { } import v2.std.qualified_name { QualifiedName, - qualified_name_from_node + qualified_name_from_node, + qualified_name_init, + qualified_name_last_segment } import v2.std.logic { Bool @@ -3413,6 +3416,9 @@ fn decode_type_expression_projection_bundle( bundle: inst_bundle ), f: fn(instantiation_form) { + bind_outcome( + o: decode_declaration_reference_form_bundle(bundle: bundle), + f: fn(declaration_reference_form) { outcome_accepted( TargetTypeExpressionProjection { atom_form: atom_form, @@ -3420,7 +3426,10 @@ fn decode_type_expression_projection_bundle( disj_form: disj_form, arrow_form: arrow_form, cardinality_form: cardinality_form, - instantiation_form: instantiation_form + instantiation_form: instantiation_form, + declaration_reference_form: declaration_reference_form + } + ) } ) } @@ -9349,6 +9358,186 @@ type TargetTypeExpressionProjection { arrow_form: TargetFunctionTypeShape cardinality_form: TargetGenericApply instantiation_form: TargetGenericApply + declaration_reference_form: DeclarationReferenceForm +} + +// HOW A TARGET SPELLS A REFERENCE TO A CORPUS DECLARATION. Resolution carries such a reference as +// the declaration's qualified path (v2.compiler.resolve resolved_reference_node), never its leaf, +// because two declarations can share a leaf across modules. A target that scopes declarations by +// module therefore owes ONE derived spelling of that path -- the seed's crate:::: +// layout, where the module segments are joined into one identifier -- and a target with no +// module-scoped path form cannot spell the reference at all: it REFUSES (located, typed) rather +// than printing the leaf, which would be the collapse resolution just refused to make. The three +// strings are realization facts about how one path token is spelled in the target, stated by the +// target's own rows in extdeps/languages, and `NoDeclarationReferenceForm` is the honest row for a +// target that has not modeled scoping -- a fixture target may declare it deliberately, which is +// what makes the refusal's red authorable (DESIGN section 4b). +type DeclarationReferenceForm + = ModuleScopedPath { + root_text: String + scope_separator_text: String + module_segment_joiner: String + } + | NoDeclarationReferenceForm + +fn declaration_reference_spelling( + root_text: String, + scope_separator_text: String, + module_segment_joiner: String, + path: QualifiedName +) -> Optional { + match qualified_name_last_segment(qn: path) { + Absent => optional_absent() + Present { value: leaf } => + let module_segments = qualified_name_init(qn: path) + if is_empty(xs: module_segments) { + optional_absent() + } else { + let module_ident = fold_list( + xs: module_segments, + empty: "", + cons: fn(acc, seg) { + if string_length(s: acc) == 0 { + symbol_lexeme(sym: seg) + } else { + concat(acc, concat(module_segment_joiner, symbol_lexeme(sym: seg))) + } + } + ) + optional_present( + value: concat( + root_text, + concat(scope_separator_text, concat(module_ident, concat(scope_separator_text, symbol_lexeme(sym: leaf)))) + ) + ) + } + } +} + +fn target_declaration_reference_form_absent_diagnostic(node: Node) -> Diagnostic { + Diagnostic { + reason: ^target_declaration_reference_form_absent, + at: node_locus(node: node), + correction: Unavailable { reason: CorrectionNotModeled } + } +} + +// A ONE-SEGMENT PATH NAMES A DECLARATION IN A ROOT THAT NAMES NO MODULE; a module-scoped form has +// no module to scope it under and refuses, rather than inventing a root-level spelling. +fn target_declaration_reference_module_absent_diagnostic(node: Node) -> Diagnostic { + Diagnostic { + reason: ^target_declaration_reference_module_absent, + at: node_locus(node: node), + correction: Unavailable { reason: CorrectionNotModeled } + } +} + +// THE EMITTED FORM OF A DECLARATION REFERENCE IS THE TARGET'S ATOM ROW OVER THE DERIVED SPELLING: +// one identifier token whose lexeme is the scoped path. The spelling is interned as the atom's +// identity so the same serialization the atom row already has (binding spelling, else the lexeme) +// prints it; nothing downstream needs a second reference-shaped emitted kind. +fn target_type_expr_declaration_reference_emitted( + projection: TargetTypeExpressionProjection, + path: QualifiedName, + reference: Node +) -> Outcome { + match projection.declaration_reference_form { + NoDeclarationReferenceForm => + outcome_rejected(target_declaration_reference_form_absent_diagnostic(node: reference)) + ModuleScopedPath { root_text: root, scope_separator_text: sep, module_segment_joiner: joiner } => + match declaration_reference_spelling( + root_text: root, + scope_separator_text: sep, + module_segment_joiner: joiner, + path: path + ) { + Absent => outcome_rejected(target_declaration_reference_module_absent_diagnostic(node: reference)) + Present { value: spelling } => + outcome_accepted( + target_type_expr_atom_emitted( + form: projection.atom_form, + identity: symbol_intern_lexeme(lexeme: spelling) + ) + ) + } + } +} + +fn target_type_expr_declaration_reference_form_node(form: DeclarationReferenceForm) -> List { + match form { + NoDeclarationReferenceForm => Empty + ModuleScopedPath { root_text: root, scope_separator_text: sep, module_segment_joiner: joiner } => + Cons { + head: target_model_named_edge( + name: ^target_type_expr_field_declaration_reference, + target: Node { + kind: TypeNode { connective: Conj }, + children: [ + target_model_named_edge( + name: ^target_type_expr_field_reference_root, + target: target_type_expr_symbol_atom(id: symbol_intern_lexeme(lexeme: root)) + ), + target_model_named_edge( + name: ^target_type_expr_field_reference_scope_separator, + target: target_type_expr_symbol_atom(id: symbol_intern_lexeme(lexeme: sep)) + ), + target_model_named_edge( + name: ^target_type_expr_field_reference_module_joiner, + target: target_type_expr_symbol_atom(id: symbol_intern_lexeme(lexeme: joiner)) + ) + ], + occurrence_id: OccurrenceSynthetic + } + ), + tail: Empty + } + } +} + +fn projection_bundle_has_child(bundle: Node, name: Symbol) -> Bool { + fold(bundle.children, init: false, f: fn(acc, e) { + acc || (match e.label { + Named { name: sym } => sym == name + Positional => false + }) + }) +} + +// ABSENT IS A ROW, NOT A MISS. A bundle without the reference child decodes to +// NoDeclarationReferenceForm, the same value a target declares when it has no scoped path form; a +// present child that is malformed refuses through the field reader like every other row. +fn decode_declaration_reference_form_bundle(bundle: Node) -> Outcome { + if projection_bundle_has_child(bundle: bundle, name: ^target_type_expr_field_declaration_reference) { + bind_outcome( + o: projection_bundle_child(bundle: bundle, name: ^target_type_expr_field_declaration_reference), + f: fn(row) { + bind_outcome( + o: projection_bundle_symbol_field(bundle: row, field: ^target_type_expr_field_reference_root), + f: fn(root) { + bind_outcome( + o: projection_bundle_symbol_field(bundle: row, field: ^target_type_expr_field_reference_scope_separator), + f: fn(sep) { + bind_outcome( + o: projection_bundle_symbol_field(bundle: row, field: ^target_type_expr_field_reference_module_joiner), + f: fn(joiner) { + outcome_accepted( + ModuleScopedPath { + root_text: symbol_lexeme(sym: root), + scope_separator_text: symbol_lexeme(sym: sep), + module_segment_joiner: symbol_lexeme(sym: joiner) + } + ) + } + ) + } + ) + } + ) + } + ) + } else { + outcome_accepted(NoDeclarationReferenceForm) + } } fn target_type_expr_surface_edge(surface: Symbol) -> Edge { @@ -12781,7 +12970,7 @@ fn type_expr_projection_row_shape_envelope( ) } -fn type_expr_projection_step_algebra(projection: TargetTypeExpressionProjection) -> Node { +fn type_expr_projection_connective_rows(projection: TargetTypeExpressionProjection) -> Node { Node { kind: TypeNode { connective: Conj }, children: [ @@ -12832,6 +13021,18 @@ fn type_expr_projection_step_algebra(projection: TargetTypeExpressionProjection) } } +fn type_expr_projection_step_algebra(projection: TargetTypeExpressionProjection) -> Node { + let rows = type_expr_projection_connective_rows(projection: projection) + Node { + kind: TypeNode { connective: Conj }, + children: list_append( + left: rows.children, + right: target_type_expr_declaration_reference_form_node(form: projection.declaration_reference_form) + ), + occurrence_id: OccurrenceSynthetic + } +} + fn type_expr_projection_shape_for_connective( projection: TargetTypeExpressionProjection, connective: Connective diff --git a/src/v2/std/qualified_name.dag b/src/v2/std/qualified_name.dag index 41c841fb300..89f4a4b5b83 100644 --- a/src/v2/std/qualified_name.dag +++ b/src/v2/std/qualified_name.dag @@ -14,7 +14,7 @@ import v2.std.diagnostic { import std.algebra { Cons, Empty, FreeMonoid } import std.decl_ref { DeclarationRef, WholeDeclaration } import std.disposition { Disposition, Scaffold, RealizationDispatch } -import v2.std.algebra { HeadAbsent, HeadFound, fold_list_right, is_empty, list_head, list_init, list_snoc_item } +import v2.std.algebra { HeadAbsent, HeadFound, fold_list_node, fold_list_right, is_empty, length, list_head, list_init, list_snoc_item } import v2.std.collection { List } @@ -27,7 +27,8 @@ import v2.std.optional { } import v2.std.compilers.lexing { symbol_intern_lexeme, symbol_lexeme } import v2.std.logic { Bool } -import v2.std.node { Atom, Conj, Edge, EdgeLabel, Named, Node, NodeFold, Positional, Symbol, TypeNode, fold_node } +import std.occurrence_identity { NodeOccurrenceIdentity } +import v2.std.node { Atom, Conj, Edge, EdgeLabel, Named, Node, NodeFold, Positional, Symbol, TypeNode, fold_node, node_synthetic, node_with_occurrence_id } import v2.std.text { String } type QualifiedName = FreeMonoid @@ -284,3 +285,43 @@ fn qualified_name_from_node(root: Node) -> Outcome { }) } } + +// THE SPINE IS THE ONE NODE SHAPE A QUALIFIED NAME HAS, IN BOTH DIRECTIONS. qualified_name_from_node +// reads it; this is its inverse, and the two are kept in one module so that no producer can spell +// the spine with labels the reader does not accept (the label set is fold_list_node's, consumed +// through qn_spine_role -- never re-spelled here). Body lowering produces the spine for an authored +// dotted reference before resolution; resolution produces it AFTER, as the resolved carrier of a +// corpus declaration reference (v2.compiler.resolve resolved_reference_node): the reference to a +// declaration is the declaration's containment path, which is the one identity the index, the +// candidate rows, DeclarationLocus and DeclarationRef already agree on (DESIGN section 3: name the +// symbol the containment tree already names, never a second scheme beside it). A resolved body +// therefore holds a corpus declaration reference as a spine and never as a bare Atom, and a bare +// Atom after resolution is a kernel canonical symbol, a literal, or a frame-local binder. +fn qualified_name_spine_node(qn: QualifiedName, occurrence_id: NodeOccurrenceIdentity) -> Node { + let spine = fold_list_node( + xs: qn, + item_node: fn(sym) { + node_synthetic(kind: TypeNode { connective: Atom { identity: sym } }, children: []) + } + ) + node_with_occurrence_id(kind: spine.kind, children: spine.children, occurrence_id: occurrence_id) +} + +// A DECLARATION REFERENCE READS AS ITS PATH OR AS NOTHING. The reader accepts only a Conj root in +// the spine shape, so a bare Atom is never a reference and a consumer asking "is this node a +// resolved reference to a corpus declaration" gets a decidable answer from the shape alone, before +// it reads the Conj as a record. A one-segment spine IS a reference -- a declaration in a root that +// names no module has a one-segment path -- and only the empty spine, which is also the empty +// record, answers Absent. A well-formed spine whose fold refuses is a malformed node, not a +// reference, and also answers Absent so that the consumer's own arm decides it. +fn declaration_reference_path_optional(node: Node) -> Optional { + if qualified_name_spine_shape_present(root: node) { + match qualified_name_from_node(root: node) { + Accepted { value: qn, diagnostics: _ } => + if length(xs: qn) > 0 { optional_present(value: qn) } else { optional_absent() } + Rejected { diagnostics: _ } => optional_absent() + } + } else { + optional_absent() + } +} diff --git a/src/v2/test/claim/manual/sg2_type_expression_projection_test.dag b/src/v2/test/claim/manual/sg2_type_expression_projection_test.dag index 81987a16a27..feddd291e97 100644 --- a/src/v2/test/claim/manual/sg2_type_expression_projection_test.dag +++ b/src/v2/test/claim/manual/sg2_type_expression_projection_test.dag @@ -261,7 +261,8 @@ fn sg2_wrong_arrow_projection() -> TargetTypeExpressionProjection { param_list_separator: row.arrow_form.param_list_separator }, cardinality_form: row.cardinality_form, - instantiation_form: row.instantiation_form + instantiation_form: row.instantiation_form, + declaration_reference_form: row.declaration_reference_form } } 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 80cbe30af3b..4d3c2091652 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 @@ -22,15 +22,20 @@ import std.repair_input_origin { SourceCarrierCandidate, SourceDeclarationCarrier } -import v2.std.algebra { fold_list, length } +import v2.std.algebra { contains, fold_list, length } import v2.extdeps.languages.dag { dag_language_model } import v2.std.artifact { Artifact, SourceFile } import v2.std.cross_tree.import_model { DagTree } import v2.std.diagnostic { Accepted, NonEmptyDiagnostics, Outcome, Rejected } import v2.std.live_tree { LiveTreeDisposition, SubstrateInputsOnly } import v2.std.logic { Bool } -import v2.std.node { Atom, Node, Symbol, TypeNode, node_subtree_nodes } -import v2.std.qualified_name { qualified_name_from_dotted_string } +import v2.std.optional { Absent, Present } +import v2.std.node { Node, Symbol, node_subtree_nodes } +import v2.std.qualified_name { + QualifiedName, + declaration_reference_path_optional, + qualified_name_from_dotted_string +} import v2.std.text { String, string_eq } import std.algebra { Cons, Empty, FreeMonoid } import extdeps.communication.medium { Lossless, Medium } @@ -42,10 +47,12 @@ data live_tree_disposition: LiveTreeDisposition = SubstrateInputsOnly // WHAT THIS FILE PROVES, AND NO MORE: that a source-produced, root-qualified spine reaches the // resolver's qualified arm and is FOUND by its CURRENT lookup, which is corpus-root ABSOLUTE // (v2.compiler.resolve try_resolve_qualified_name_node, whose own header marks unique-on-chain -// UNFIRED), with every refusal it raises typed and located: unbound for a miss, ambiguous for a -// head also bound on the chain, identity-unrepresentable for a hit on a non-atom declaration. It -// does NOT establish docs/plans/namespace-resolution-design.md section 13 projection, nor -// exact declaring identity -- those are measured by today-rows below, which are their triggers. +// UNFIRED), that a found declaration is ACCEPTED carrying its exact declaring identity -- the +// declaration's qualified path, as a qualified-name spine (v2.compiler.resolve +// resolved_reference_node) -- and that every refusal it raises is typed and located: unbound for a +// miss, ambiguous for a head also bound on the chain. It does NOT establish +// docs/plans/namespace-resolution-design.md section 13 projection, which is measured by today-rows +// below that are its trigger. // // v2.test.claim.namespace_xl0.call_argument_mention_survival measures what the COLLECTOR sees. This // file measures what RESOLUTION does with the same lowered shape, because the two consume one @@ -53,10 +60,16 @@ data live_tree_disposition: LiveTreeDisposition = SubstrateInputsOnly // (v2.compiler.body_lowering_fold body_lower_dotted_reference_optional). A spine the resolver // ACCEPTS by widening what resolves would be the fail-open arm DESIGN section 5 forbids, and a // spine it refuses without saying why would be silence. So the rows are a matched set and the -// boundary is the REASON: reassembling the chain must make a DECLARED cross-module target FOUND -- -// refused identity-unrepresentable today, because the resolver carries no exact declaring -// identity -- and must leave an UNDECLARED one refusing unbound. Deliberate, typed refusal is the -// correct interim result; nothing Accepts a qualified chain today. +// boundary is the IDENTITY: a DECLARED cross-module target resolves to its own path and to no other +// declaration's, and an UNDECLARED one refuses unbound. +// +// THE DEFECT PROPER IS THE BARE-NAME ROW, NOT THE QUALIFIED ONE (fierce-wren-487, 2026-09-22). The +// qualified door refused a found fn with a typed reason while resolution carried only a leaf; the +// BARE door did not refuse at all: two consumers importing `xl0r_same_leaf` from two providers each +// resolved, Accepted, to the same Atom -- one declaration's identity silently standing for +// another's, with nothing downstream able to tell. That row (two_import_bound_same_leaf_targets...) +// is the discriminating red: it fails against a resolver that mints the leaf and holds against one +// that carries the declaring path. // // WHAT RESOLVES HERE IS THE SUBJECT ROOT ALONE. v2.compiler.name_resolve resolve_with_admission_context_policy // resolves the module the Admission names and consults the other ingested roots only through the @@ -90,6 +103,10 @@ data xl0r_consumer_a_source: String = "module v2.test.xl0r_consumer_a\n\nimport data xl0r_consumer_b_source: String = "module v2.test.xl0r_consumer_b\n\nimport v2.std.logic { Bool }\n\nfn xl0r_uses_b() -> Bool { v2.test.xl0r_provider_b.xl0r_same_leaf }\n" +data xl0r_import_consumer_a_source: String = "module v2.test.xl0r_import_consumer_a\n\nimport v2.std.logic { Bool }\nimport v2.test.xl0r_provider_a { xl0r_same_leaf }\n\nfn xl0r_import_uses_a() -> Bool { xl0r_same_leaf }\n" + +data xl0r_import_consumer_b_source: String = "module v2.test.xl0r_import_consumer_b\n\nimport v2.std.logic { Bool }\nimport v2.test.xl0r_provider_b { xl0r_same_leaf }\n\nfn xl0r_import_uses_b() -> Bool { xl0r_same_leaf }\n" + data xl0r_field_access_consumer_source: String = "module v2.test.xl0r_field_access_consumer\n\nimport v2.std.logic { Bool }\n\nfn xl0r_projects(r: Bool) -> Bool { r.v }\n" data xl0r_method_call_consumer_source: String = "module v2.test.xl0r_method_call_consumer\n\nimport v2.std.logic { Bool }\n\nfn xl0r_calls_through(r: Bool, p: Bool) -> Bool { r.v(x: p) }\n" @@ -164,7 +181,13 @@ data xl0r_ingest: SourceRootIngest = Cons { head: xl0r_read(source: xl0r_consumer_b_source, id: ^xl0r_consumer_b_read, unit: ^xl0r_consumer_b_cu, path: "src/v2/test/fixture/namespace_xl0/resolution_consumer_b.dag"), tail: Cons { head: xl0r_read(source: xl0r_method_call_consumer_source, id: ^xl0r_method_call_consumer_read, unit: ^xl0r_method_call_consumer_cu, path: "src/v2/test/fixture/namespace_xl0/resolution_method_call_consumer.dag"), - tail: Empty + tail: Cons { + head: xl0r_read(source: xl0r_import_consumer_a_source, id: ^xl0r_import_consumer_a_read, unit: ^xl0r_import_consumer_a_cu, path: "src/v2/test/fixture/namespace_xl0/resolution_import_consumer_a.dag"), + tail: Cons { + head: xl0r_read(source: xl0r_import_consumer_b_source, id: ^xl0r_import_consumer_b_read, unit: ^xl0r_import_consumer_b_cu, path: "src/v2/test/fixture/namespace_xl0/resolution_import_consumer_b.dag"), + tail: Empty + } + } } } } @@ -260,15 +283,44 @@ fn xl0r_resolved_consumer_b() -> Outcome { xl0r_resolve_subject(subject: "v2.test.xl0r_consumer_b") } -fn xl0r_resolved_atom_present(root: Node, identity: Symbol) -> Bool { - fold(node_subtree_nodes(root: root), init: false, f: fn(acc, n) { - acc || (match n.kind { - TypeNode { connective: Atom { identity: sym } } => sym == identity - _ => false - }) +fn xl0r_resolved_import_consumer_a() -> Outcome { + xl0r_resolve_subject(subject: "v2.test.xl0r_import_consumer_a") +} + +fn xl0r_resolved_import_consumer_b() -> Outcome { + xl0r_resolve_subject(subject: "v2.test.xl0r_import_consumer_b") +} + +// THE DECLARING PATHS A RESOLVED BODY CARRIES, read through the one reader the carrier has +// (v2.std.qualified_name declaration_reference_path_optional): every qualified-name spine of two or +// more segments in the resolved subtree. An Atom is not a reference to a corpus declaration after +// resolution, so a resolver that minted the leaf answers an EMPTY list here and every row below +// that asks for a path reds against it. +fn xl0r_resolved_reference_paths(root: Node) -> FreeMonoid { + fold(node_subtree_nodes(root: root), init: Empty, f: fn(acc, n) { + match declaration_reference_path_optional(node: n) { + Present { value: qn } => Cons { head: qn, tail: acc } + Absent => acc + } }) } +fn xl0r_qualified_name_eq(a: QualifiedName, b: QualifiedName) -> Bool { + a == b +} + +fn xl0r_resolved_carries_reference(o: Outcome, dotted: String) -> Bool { + match o { + Accepted { value: root, diagnostics: _ } => + contains( + xs: xl0r_resolved_reference_paths(root: root), + item: qualified_name_from_dotted_string(dotted: dotted), + eq: xl0r_qualified_name_eq + ) + Rejected { diagnostics: _ } => false + } +} + // The FINAL refusal is the one the resolver raised, behind whatever pending parse-stage residue the // pipeline carries forward (rejected_with_pending prepends the pending rows, so `head` is not it). fn xl0r_last_reason(d: NonEmptyDiagnostics) -> Symbol { @@ -292,46 +344,53 @@ test fn a_same_module_reference_resolves_on_the_production_route_holds() -> Bool } } -// THE PRIMARY, AT THE RUNG IT ACTUALLY HAS: a cross-module dotted reference to a DECLARED function -// REACHES the resolver's qualified arm and is FOUND -- and is then refused -// resolve_reason_qualified_target_identity_unrepresentable, because the resolved value is one -// canonical Atom and a fn found by its path is a declaration, not a symbol. The road here: before -// the lowering repair this module refused resolve_reason_unbound_symbol on the bare `v2` the infix -// arm left behind; one revision Accepted it carrying `resolve_reason_unbound_symbol` AS THE -// IDENTITY; the next carried the leaf name, which the side-chat ruling on gunbc#11582 found equally -// fabricated (two `run`s in two modules collapse to one Atom -- the pair of rows after this one is -// that discriminator). So the honest verdict today is a located refusal that says WHY, and the -// difference from the undeclared row beside it is the reason: found-but-unrepresentable versus -// unbound. This row reds when resolution carries exact declaring identity, and is then re-stated -// as resolving to that identity. -test fn a_cross_module_reference_to_a_declared_fn_is_found_and_refuses_identity_unrepresentable_today() -> Bool { - match xl0r_resolved_declared_consumer() { - Accepted { value: _, diagnostics: _ } => false - Rejected { diagnostics: d } => - xl0r_last_reason(d: d) == ^resolve_reason_qualified_target_identity_unrepresentable - } +// THE PRIMARY: a cross-module dotted reference to a DECLARED function REACHES the resolver's +// qualified arm, is FOUND, and is ACCEPTED carrying the declaration's own path -- not its leaf, not +// a reason symbol. The road here: before the lowering repair this module refused +// resolve_reason_unbound_symbol on the bare `v2` the infix arm left behind; one revision Accepted it +// carrying `resolve_reason_unbound_symbol` AS THE IDENTITY; the next carried the leaf name, which +// the side-chat ruling on gunbc#11582 found equally fabricated (two `run`s in two modules collapse +// to one Atom); the landed interim REFUSED with resolve_reason_qualified_target_identity_unrepresentable, +// and this row asserted that refusal. The carrier landed (fierce-wren-487), the refusal is deleted, +// and the row is re-stated as DESIGN section 4b(4) says it must be: the accepted body carries +// exactly the path v2.test.xl0r_provider.xl0r_provided_fn. +test fn a_cross_module_reference_to_a_declared_fn_resolves_to_its_declaring_path_holds() -> Bool { + xl0r_resolved_carries_reference( + o: xl0r_resolved_declared_consumer(), + dotted: "v2.test.xl0r_provider.xl0r_provided_fn" + ) } // THE DECLARATION-IDENTITY DISCRIMINATOR (side-chat ruling on gunbc#11582, finding 1). Two // providers declare the same leaf `xl0r_same_leaf`; two consumers each name one by its full path. // A resolver that answered with the LEAF would make the two references indistinguishable, and the -// import cut's replacement graph cannot be built on leaf names. Today both refuse -// identity-unrepresentable -- found, distinct paths, no carrier for the distinction -- and when -// the exact-identity carrier lands these rows must be re-stated to assert the two resolved -// identities DIFFER, never that both resolve. -test fn two_same_leaf_targets_in_different_modules_are_not_collapsed_to_one_identity_holds() -> Bool { +// import cut's replacement graph cannot be built on leaf names. Both were refused +// identity-unrepresentable while no carrier existed; re-stated on the carrier's landing, as the +// previous revision said it must be: each resolves to ITS provider's path, and neither carries the +// other's -- the identities DIFFER, which is the fact, rather than "both resolve". +test fn two_same_leaf_targets_in_different_modules_resolve_to_distinct_declaring_paths_holds() -> Bool { let a = xl0r_resolved_consumer_a() let b = xl0r_resolved_consumer_b() - match a { - Accepted { value: _, diagnostics: _ } => false - Rejected { diagnostics: da } => - match b { - Accepted { value: _, diagnostics: _ } => false - Rejected { diagnostics: db } => - xl0r_last_reason(d: da) == ^resolve_reason_qualified_target_identity_unrepresentable - && xl0r_last_reason(d: db) == ^resolve_reason_qualified_target_identity_unrepresentable - } - } + xl0r_resolved_carries_reference(o: a, dotted: "v2.test.xl0r_provider_a.xl0r_same_leaf") + && xl0r_resolved_carries_reference(o: b, dotted: "v2.test.xl0r_provider_b.xl0r_same_leaf") + && !xl0r_resolved_carries_reference(o: a, dotted: "v2.test.xl0r_provider_b.xl0r_same_leaf") + && !xl0r_resolved_carries_reference(o: b, dotted: "v2.test.xl0r_provider_a.xl0r_same_leaf") +} + +// THE DEFECT PROPER, AT THE BARE DOOR. Two consumers import `xl0r_same_leaf`, one from each +// provider, and reference it BARE. The import is a binding on the consumer's chain (gunbc#12009), +// the lexical lookup names the binder by its DECLARING path, and resolve_atom_bound used to throw +// that path away and mint canonical_atom(identity: xl0r_same_leaf) for both: Accepted, equal, and +// no refusal anywhere -- one declaration's identity standing silently for another's. Against that +// resolver this row is RED (xl0r_resolved_reference_paths answers Empty for an Atom); against the +// carrier it holds: each body carries its own provider's path and not the other's. +test fn two_import_bound_same_leaf_targets_resolve_to_distinct_declaring_paths_holds() -> Bool { + let a = xl0r_resolved_import_consumer_a() + let b = xl0r_resolved_import_consumer_b() + xl0r_resolved_carries_reference(o: a, dotted: "v2.test.xl0r_provider_a.xl0r_same_leaf") + && xl0r_resolved_carries_reference(o: b, dotted: "v2.test.xl0r_provider_b.xl0r_same_leaf") + && !xl0r_resolved_carries_reference(o: a, dotted: "v2.test.xl0r_provider_b.xl0r_same_leaf") + && !xl0r_resolved_carries_reference(o: b, dotted: "v2.test.xl0r_provider_a.xl0r_same_leaf") } // THE BOUNDARY: the same chain shape naming a declaration that does not exist refuses, at resolve, @@ -343,20 +402,16 @@ test fn a_cross_module_reference_to_an_undeclared_name_refuses_unbound() -> Bool } } -// RE-STATED 2026-09-19 (DESIGN 4b(4)), as the previous revision said it must be when declaration -// grafting landed (gunbc#11574): a DATA declaration is now a Named containment edge at its content -// (the `: Type = init` remainder), so the chain is FOUND -- the unbound refusal is gone -- and the -// hit lands on the same frontier a declared fn sits on: the target is not an Atom, so the resolver -// refuses resolve_reason_qualified_target_identity_unrepresentable until the declaring-identity -// carrier lands (03_resolve qualified_target_identity_unrepresentable_diagnostic). The reason -// moving from unbound to identity-unrepresentable is the climb this row records; the row flips -// again to Accepted when that carrier lands. -test fn a_cross_module_reference_to_a_data_declaration_is_found_and_refuses_identity_unrepresentable_today() -> Bool { - match xl0r_resolved_data_target_consumer() { - Accepted { value: _, diagnostics: _ } => false - Rejected { diagnostics: d } => - xl0r_last_reason(d: d) == ^resolve_reason_qualified_target_identity_unrepresentable - } +// RE-STATED TWICE (DESIGN 4b(4)). When declaration grafting landed (gunbc#11574) a DATA +// declaration became a Named containment edge at its content, so the chain was FOUND and the row +// moved from unbound to the interim identity-unrepresentable refusal. When the declaring-identity +// carrier landed (fierce-wren-487) the refusal was deleted and the row flips again, as it said it +// would: Accepted, carrying the data declaration's own path. +test fn a_cross_module_reference_to_a_data_declaration_resolves_to_its_declaring_path_holds() -> Bool { + xl0r_resolved_carries_reference( + o: xl0r_resolved_data_target_consumer(), + dotted: "v2.test.xl0r_provider.xl0r_provided_value" + ) } // THE LOCAL-RECEIVER CLASS: A NEGATIVE CONTROL, NOT A TRIGGER (side-chat ruling on gunbc#11582, diff --git a/src/v2/test/claim/translate/declaration_reference_form_test.dag b/src/v2/test/claim/translate/declaration_reference_form_test.dag new file mode 100644 index 00000000000..644beda0384 --- /dev/null +++ b/src/v2/test/claim/translate/declaration_reference_form_test.dag @@ -0,0 +1,155 @@ +module v2.test.claim.translate.declaration_reference_form + +import std.occurrence_identity { OccurrenceSynthetic } +import v2.compiler.translate { + target_serialize_source_from_model, + translate_type_expression_project +} +import v2.extdeps.languages.rust { rust_type_expression_projection } +import v2.extdeps.languages.rust_test { rust_sg2_type_expression_projection_target_model } +import v2.std.compilers.target_model { + DeclarationReferenceForm, + ModuleScopedPath, + NoDeclarationReferenceForm, + TargetModel, + TargetTypeExpressionProjection, + type_expression_projection_from_target +} +import v2.std.diagnostic { Accepted, NonEmptyDiagnostics, Outcome, Rejected } +import v2.std.live_tree { LiveTreeDisposition, SubstrateInputsOnly } +import v2.std.logic { Bool } +import v2.std.node { Atom, Node, Symbol, TypeNode } +import v2.std.qualified_name { qualified_name_from_dotted_string, qualified_name_spine_node } +import v2.std.text { String } + +data live_tree_disposition: LiveTreeDisposition = SubstrateInputsOnly + +// THE TRANSLATE SIDE OF THE DECLARING-IDENTITY CARRIER, AT ONE INTERFACE. Resolution carries a +// reference to a corpus declaration as its qualified path -- a qualified-name spine +// (v2.compiler.resolve resolved_reference_node) -- and the type-expression fold +// (v2.compiler.translate translate_algebra) must emit that spine through the target's declaration +// reference form (v2.std.compilers.target_model DeclarationReferenceForm), never as the record its +// Conj connective resembles and never as its leaf. The inputs here are SUPPLIED at the boundary +// (DESIGN section 3, a witness discriminates at one interface): the spine is built by the same +// constructor resolution uses, and the projection is the rust row or a fixture row derived from +// it. The inhabitance claim that the real resolver emits this shape is +// v2.test.claim.namespace_xl0.cross_module_reference_resolution. +// +// TWO SUBJECTS, STATED SEPARATELY BECAUSE A CENSUS OVER ONE IS NOT A CONTROL FOR THE OTHER. The rows +// below are over (a) the FOLD: a spine is emitted as the derived module-scoped spelling, a spine +// with no form REFUSES located, and a bare Atom is unchanged; and (b) the TARGET ROWS: the rust +// projection declares a module-scoped path form and the bundle a target actually hands the fold +// decodes to that same form. Neither says anything about how many declarations share a leaf; that +// is a different subject with its own instrument. + +data drf_target: TargetModel = rust_sg2_type_expression_projection_target_model() + +data drf_expected_spelling: String = "crate::v2_test_drf_provider::DrfDeclared" + +fn drf_reference_spine() -> Node { + qualified_name_spine_node( + qn: qualified_name_from_dotted_string(dotted: "v2.test.drf_provider.DrfDeclared"), + occurrence_id: OccurrenceSynthetic + ) +} + +fn drf_bare_atom() -> Node { + Node { + kind: TypeNode { connective: Atom { identity: ^DrfBareAtom } }, + children: [], + occurrence_id: OccurrenceSynthetic + } +} + +// THE FIXTURE ROW WITH NO PATH FORM: the rust projection with its reference form withdrawn. This is +// what makes the refusal's red authorable (DESIGN section 4b): no emitted target lacks the form +// today, and a refusal whose population is empty and whose red cannot be written is a decoration. +fn drf_projection_without_reference_form() -> TargetTypeExpressionProjection { + let row = rust_type_expression_projection() + TargetTypeExpressionProjection { + atom_form: row.atom_form, + conj_form: row.conj_form, + disj_form: row.disj_form, + arrow_form: row.arrow_form, + cardinality_form: row.cardinality_form, + instantiation_form: row.instantiation_form, + declaration_reference_form: NoDeclarationReferenceForm + } +} + +fn drf_last_reason(d: NonEmptyDiagnostics) -> Symbol { + fold(d.tail, init: d.head.reason, f: fn(acc, x) { x.reason }) +} + +fn drf_serialized(emitted: Outcome) -> Outcome { + match emitted { + Rejected { diagnostics: d } => Rejected { diagnostics: d } + Accepted { value: node, diagnostics: _ } => + match target_serialize_source_from_model(target: drf_target, emitted: node) { + Accepted { value: source, diagnostics: d } => Accepted { value: source.carried, diagnostics: d } + Rejected { diagnostics: d } => Rejected { diagnostics: d } + } + } +} + +// (a) THE FOLD. A reference spine under the rust row emits the crate-rooted, module-joined path. +test fn a_declaration_reference_emits_the_module_scoped_spelling_holds() -> Bool { + match drf_serialized( + emitted: translate_type_expression_project( + node: drf_reference_spine(), + target: drf_target, + projection: rust_type_expression_projection() + ) + ) { + Accepted { value: text, diagnostics: _ } => text == drf_expected_spelling + Rejected { diagnostics: _ } => false + } +} + +// THE RED: the same spine under a row with no path form refuses, located, with the form-absent +// reason -- and does not print the leaf. +test fn a_declaration_reference_with_no_path_form_refuses_located() -> Bool { + match translate_type_expression_project( + node: drf_reference_spine(), + target: drf_target, + projection: drf_projection_without_reference_form() + ) { + Accepted { value: _, diagnostics: _ } => false + Rejected { diagnostics: d } => drf_last_reason(d: d) == ^target_declaration_reference_form_absent + } +} + +// THE CONTROL: a bare Atom is not a declaration reference and still emits through the atom row, +// under either projection -- the arm added for the spine changed nothing for it. +test fn a_bare_atom_still_emits_through_the_atom_row_holds() -> Bool { + match drf_serialized( + emitted: translate_type_expression_project( + node: drf_bare_atom(), + target: drf_target, + projection: drf_projection_without_reference_form() + ) + ) { + Accepted { value: text, diagnostics: _ } => text == "DrfBareAtom" + Rejected { diagnostics: _ } => false + } +} + +fn drf_form_is_module_scoped(form: DeclarationReferenceForm) -> Bool { + match form { + ModuleScopedPath { root_text: _, scope_separator_text: _, module_segment_joiner: _ } => true + NoDeclarationReferenceForm => false + } +} + +// (b) THE TARGET ROWS. The rust projection declares a module-scoped path form, and the bundle the +// fold reads at run time (the encoded projection a TargetModel carries) decodes to a module-scoped +// form too -- the encoder and decoder agree, so a target cannot declare the form and hand the fold +// a bundle without it. +test fn the_rust_target_declares_a_module_scoped_path_form_and_its_bundle_carries_it_holds() -> Bool { + drf_form_is_module_scoped(form: rust_type_expression_projection().declaration_reference_form) + && (match type_expression_projection_from_target(target: drf_target) { + Accepted { value: decoded, diagnostics: _ } => + drf_form_is_module_scoped(form: decoded.declaration_reference_form) + Rejected { diagnostics: _ } => false + }) +} diff --git a/src/v2/workflow/compile_door_cause_ownership.dag b/src/v2/workflow/compile_door_cause_ownership.dag index 5e34b8a959b..c8da686b5bb 100644 --- a/src/v2/workflow/compile_door_cause_ownership.dag +++ b/src/v2/workflow/compile_door_cause_ownership.dag @@ -155,12 +155,6 @@ data known_frontier_causes: List = [ lane: SharedSelfHostCriticalPath, flip_trigger: "resolve stage frontier on the self-host critical path; flips to Blocking when the count zeroes. Measured 2026-09-10 at fatal grain by the gunbc.witness_v2_native_route census over the lane's source roots dag + src/v2 (2059 v2.test.* members across 417 modules, dominantly the parse frontier's import cascade: the member's own file parses while a file in its import closure refuses, so the imported names resolve to nothing — zeroing rides the parse-frontier rows beside resolve's own binding gaps, the head-grain row's thread)" }, - CauseOwnership { - cause: ^resolve_reason_qualified_target_identity_unrepresentable, - grain: FatalGrain, - lane: SharedSelfHostCriticalPath, - flip_trigger: "resolve stage frontier on the self-host critical path, reachable since gunbc#11582 built the qualified-name spine for suffix-free dotted chains: a fully-qualified reference to a fn declaration (an Arrow) is FOUND and refused because the resolver carries no exact declaring identity (gunbc.compiler_frontend_program_status ResolverBoundDeclaringIdentity). Before that PR the same sources refused resolve_reason_unbound_symbol on the truncated `v2` atom, so this row's population is a re-attribution of part of that row's, not a new loss. Flips to Blocking when the count zeroes, which the exact-identity carrier owns (review 67716 on gunbc#11582)" - }, CauseOwnership { cause: ^resolve_reason_ambiguous_symbol, grain: FatalGrain, From 5e7c2b8080b603bfa98a2e3d6a86b2496ae996d0 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Tue, 22 Sep 2026 04:41:01 +0000 Subject: [PATCH 09/17] The live pair is OBSERVED: standing flips to required, and its rung drop retires by its own trigger Review 69926 found that this PR deletes the class native_route_live_pair_standing was pinned to, orphaning an enrolled 4b(4) probe. The manager ruled the flip is gated on the OBSERVATION, not on resolve -- flipping on resolve alone would be the rung inflation 4b(1) names -- so the observation was executed before this edit, not predicted by it. EXECUTED, in this order, on the emitted route: 1. v2.native_lane_fixture.control RESOLVES. Real source bytes (control.dag, live_tree.dag, logic.dag), not an isomorphic fixture. 2. Through the emitted binary's own native_test_eval_one: native_lane_false_control = ReturnedFalse native_lane_true_control = Passed That is the first observation of the pair on ANY head. It could not be observed on main 79e745b4 or on gunbc#11952, because the module both controls live in refused at prepare with resolve_ambiguous_on_global_bare on the bare SubstrateInputsOnly. 3. Standing set to LivePairRequired here, in the same change, as the trigger requires. Receipt: dag head 5aee537033 plus the two emitter files from #12026 (origin/session/sunny-ibex-112 @ 0f0ee6fa25, cherry-picked -- merging that branch whole drags in main's extdeps_numeric_base16 UInt8 gap and the emitted crate will not compile); emitting gunbc sha256 682bfa1247cd3a04; emitted closure 190 files sha256 038f40828900249f; probe sha256 1d5a6a98ebc5dac6. Command in the PR body. IT FLIPS, IT DOES NOT RETIRE (DESIGN 4b(4)). The pair is a permanent regression control from here: a route that stops discriminating false from true reds this clause. What the enrolled-red form bought was tolerance of a refusal no lane change could move, and that refusal is gone. gunbc.rung_drop.native_lane_live_pair_expected_red is Retired by its own trigger and by nothing else, with the three conjuncts recorded on the row; docs/design-rung-drops.md regenerated rather than hand-edited. The leading annotation no longer describes the enrolled arm as current. It keeps WHY that arm pins three axes, because that is how a future stall would be declared: an arm pinned to stage, fatal reason AND lookup class cannot be satisfied by the subject getting worse in a new way, and cannot outlive the condition it describes -- which is exactly how this one failed when the class was deleted underneath it. Co-Authored-By: Claude Opus 5 (1M context) --- .../native_lane_live_pair_expected_red.dag | 6 ++- dag/gunbc/witness/v2_native_route.dag | 54 ++++++++++++------- docs/design-rung-drops.md | 4 +- 3 files changed, 42 insertions(+), 22 deletions(-) diff --git a/dag/gunbc/rung_drop/native_lane_live_pair_expected_red.dag b/dag/gunbc/rung_drop/native_lane_live_pair_expected_red.dag index 37da94222f2..0749b40c593 100644 --- a/dag/gunbc/rung_drop/native_lane_live_pair_expected_red.dag +++ b/dag/gunbc/rung_drop/native_lane_live_pair_expected_red.dag @@ -1,7 +1,7 @@ module gunbc.rung_drop.native_lane_live_pair_expected_red import std.types { List, NonEmptyStr } -import gunbc.rung_drop { RungDrop, Standing, TypedDeclaration, ReplacementStaged } +import gunbc.rung_drop { RungDrop, Retired, TypedDeclaration, ReplacementStaged } import gunbc.guarantee_rung { Mitigatable, MechanicallyPreventable } // DECLARED 2026-09-21 (v2 foundation manager ruling, no-regression standard for gunbc#11952; the @@ -34,7 +34,9 @@ data native_lane_live_pair_expected_red: RungDrop = RungDrop { declared: "2026-09-21", - standing: Standing, + standing: Retired { + trigger_fired: "2026-09-22, gunbc#12009. All three conjuncts executed on the emitted route before the edit: (1) v2.native_lane_fixture.control RESOLVES under the namespace candidate rule -- measured on the real source bytes, not an isomorphic fixture; (2) a run through the emitted binary's native_test_eval_one observed native_lane_false_control = ReturnedFalse AND native_lane_true_control = Passed, the first observation of the pair on any head; (3) gunbc.witness_v2_native_route native_route_live_pair_standing set to LivePairRequired in the same change. The pair is now a permanent regression control (DESIGN 4b(4)), not retired evidence. Receipt -- dag head, emitter source, emitted-closure hash, both verdicts, command -- in gunbc#12009's body." as NonEmptyStr + }, declaration: TypedDeclaration { previous: MechanicallyPreventable, diff --git a/dag/gunbc/witness/v2_native_route.dag b/dag/gunbc/witness/v2_native_route.dag index 4487a9eef32..87969ad808c 100644 --- a/dag/gunbc/witness/v2_native_route.dag +++ b/dag/gunbc/witness/v2_native_route.dag @@ -904,29 +904,45 @@ data native_route_live_control_false_declaration: String = "native_lane_false_co data native_route_live_control_true_declaration: String = "native_lane_true_control" // THE LIVE PAIR'S STANDING: REQUIRED, OR ENROLLED AS AN EXPECTING-RED PROBE PINNED TO ITS CAUSE -// (manager ruling 2026-09-21). On main 79e745b4 and on gunbc#11952 alike, the module both controls -// live in, `v2.native_lane_fixture.control`, is refused at PREPARE with -// `resolve_ambiguous_on_global_bare` on the bare `SubstrateInputsOnly` (the emitted binary's own -// `census-resolve` names it), as is the whole population -- so neither control can be observed by -// any head until the native resolver admits that form. Leaving the clause required would make the -// lane unadmittable for a reason no lane change can move; dropping it would lose the control. -// Enrolling it keeps the evidence: the clause holds ONLY while both controls refuse at the pinned -// stage with the pinned FATAL reason and the pinned lookup CLASS in their chain. A DIFFERENT refusal is a moved wall and refuses as not -// observed; an OBSERVED control refuses as `LivePairObservedWhileEnrolledRed`, which is the flip -// signal -- the standing must then become `LivePairRequired`, and the pair is a required-green -// control from that head on (DESIGN 4b(4): the probe flips to a permanent regression control, it -// does not retire). RETIREMENT TRIGGER, the capability: the native resolver admits the retained -// forms (v2 foundation package 12, the namespace candidate rule). +// (manager ruling 2026-09-21). The enrolled arm was declared because, on main 79e745b4 and on +// gunbc#11952 alike, the module both controls live in -- `v2.native_lane_fixture.control` -- was +// refused at PREPARE with `resolve_ambiguous_on_global_bare` on the bare `SubstrateInputsOnly`, as +// was the whole population: no head could observe the pair until the native resolver admitted that +// form, and a required clause would have refused every receipt for a reason no lane change could +// move. THAT CONDITION ENDED on 2026-09-22 (gunbc#12009), and the standing below is now +// `LivePairRequired`; the enrolled arm is kept in the type because it is how a future stall would +// be declared, not because anything is enrolled in it today. +// +// WHY THE ENROLLED ARM PINS THREE AXES, worth keeping now that it is unused: the clause held ONLY +// while both controls refused at the pinned stage with the pinned FATAL reason and the pinned +// lookup CLASS in their chain. A DIFFERENT refusal is a moved wall and refuses as not observed; an +// OBSERVED control refuses as `LivePairObservedWhileEnrolledRed`, which is the flip signal. That +// is what makes the arm a probe rather than a hole: it cannot be satisfied by the subject getting +// worse in a new way, and it cannot outlive the condition it describes. type NativeRouteLivePairStanding = LivePairRequired | LivePairExpectedRed { stage: NativeTestStage, fatal_reason: Symbol, class_reason: Symbol, trigger: String } -data native_route_live_pair_standing: NativeRouteLivePairStanding = LivePairExpectedRed { - stage: NativeTestStagePrepare, - fatal_reason: ^resolve_reason_ambiguous_symbol, - class_reason: ^resolve_ambiguous_on_global_bare, - trigger: "the native resolver admits the retained forms (v2 foundation package 12, the namespace candidate rule)" -} +// FLIPPED TO REQUIRED 2026-09-22 (gunbc#12009, v2 foundation package 12 -- the trigger this row +// named). The pair is OBSERVED. All three conjuncts of the retirement trigger were executed on the +// emitted route before this edit, which is the order DESIGN 4b(1) requires -- the reported rung +// must equal the rung executed evidence establishes, and flipping on "it resolves" alone would +// have been the inflation that section names: +// 1. v2.native_lane_fixture.control RESOLVES (real source bytes, not an isomorphic fixture); +// 2. native_lane_false_control observed ReturnedFalse AND native_lane_true_control observed +// Passed, through the emitted binary's own native_test_eval_one; +// 3. this standing set to LivePairRequired in the same change. +// The receipt -- head, emitter source, emitted-closure hash, both verdicts, the command -- is in +// gunbc#12009's body. +// +// IT DOES NOT RETIRE, IT FLIPS (DESIGN 4b(4)): the pair is now a permanent regression control, and +// a route that stops discriminating false from true reds this clause from here on. What the +// enrolled-red form bought was tolerance of a refusal no lane change could move; that refusal is +// gone, so the tolerance goes with it. The class it was pinned to -- resolve_ambiguous_on_global_bare +// -- no longer has a producer, which is why leaving the pin standing was not an option: an +// expecting-red probe pinned to an unproducible cause can never match, reads as a moved wall for +// every future head, and can never be retired by its own trigger. +data native_route_live_pair_standing: NativeRouteLivePairStanding = LivePairRequired // A CONTROL MATCHES THE ENROLLMENT ONLY AS THE PINNED REFUSAL, ON ALL THREE AXES: the stage, the // FATAL reason (`diagnostics_fatal_reason` -- the last diagnostic, never the head, which is diff --git a/docs/design-rung-drops.md b/docs/design-rung-drops.md index 6f34192db7c..e125303c591 100644 --- a/docs/design-rung-drops.md +++ b/docs/design-rung-drops.md @@ -226,7 +226,9 @@ admission rows on falsifier-family cadences with no scheduled route, awaiting tr The v2 native route (emitted-native compiler over the derived v2.test.* universe) as a required CI lane: RUNG DROP, mechanically preventable -> mitigatable (deleted without replacement). Population: gunbc.witness_v2_native_route native_route_admission — every receipt clause, on every merge candidate, the derived v2.test.* universe executed by the emitted-native compiler (positive population), v2.native_lane_fixture.control native_lane_false_control / native_lane_true_control (live-verdict controls), fixtures/native_lane_malformed/poison.dag.poisoned (malformed-specimen control), the old-route-withdrawn (no-fallback) control. Restored when: A REQUIRED NATIVE-ROUTE JOB WHOSE WALL FITS THE ACCEPTANCE PATH, designed from the ground up against an operator-agreed contract and not re-added into the same envelope: the job must conclude on every merge candidate inside its declared timeout on the real runner class (a measured wall, not a cited one), a newer head of the same subject must supersede the older run so no head holds more than one native claimant, and its red must still discriminate every clause of native_route_admission — universe join, positive population, classified refusals, per-identity agreement with the floor reference, and all four controls -- with the live false/true pair OBSERVED at gunbc.witness_v2_native_route native_route_live_pair_standing = LivePairRequired, not held by the expecting-red enrollment (gunbc.rung_drop.native_lane_live_pair_expected_red must be retired first, or retire in the same change). Whether that is reached by affected-set admission that runs only the universe a change touches, by a native run cheap enough to fit whole, or by a job that runs on a different cadence with its own row, is the design decision this drop waits on; the first two retire this row, the third replaces it with a differently-named drop. Re-adding `required-v2-native` with its 2026-09-10 shape does not retire this row and is refused by test.claim.witness_floor_workflow_consolidation_witness_test w_RED_the_deleted_lanes_do_not_return. -### The native lane's live-verdict control pair as a required admission clause — declared 2026-09-21 +### The native lane's live-verdict control pair as a required admission clause — declared 2026-09-21 · RETIRED + +**RETIRED — TRIGGER FIRED.** 2026-09-22, gunbc#12009. All three conjuncts executed on the emitted route before the edit: (1) v2.native_lane_fixture.control RESOLVES under the namespace candidate rule -- measured on the real source bytes, not an isomorphic fixture; (2) a run through the emitted binary's native_test_eval_one observed native_lane_false_control = ReturnedFalse AND native_lane_true_control = Passed, the first observation of the pair on any head; (3) gunbc.witness_v2_native_route native_route_live_pair_standing set to LivePairRequired in the same change. The pair is now a permanent regression control (DESIGN 4b(4)), not retired evidence. Receipt -- dag head, emitter source, emitted-closure hash, both verdicts, command -- in gunbc#12009's body. The native lane's live-verdict control pair as a required admission clause: RUNG DROP, mechanically preventable -> mitigatable (replacement staged: gunbc.witness_v2_native_route LivePairExpectedRed: the clause holds only at the pinned refusal (stage prepare; fatal resolve_reason_ambiguous_symbol read as the chain's last diagnostic; class resolve_ambiguous_on_global_bare present in the chain), refuses a moved wall as not observed, and refuses an observed control as live_pair_observed_while_enrolled_red). Population: v2.native_lane_fixture.control native_lane_false_control (live-verdict control: must return false), v2.native_lane_fixture.control native_lane_true_control (live-verdict control: must pass). Restored when: THE LIVE PAIR IS OBSERVED ON THE NATIVE ROUTE AND THE STANDING FLIPS: the native resolver admits the retained forms (v2 foundation package 12, the namespace candidate rule) so that v2.native_lane_fixture.control resolves, a native lane run observes native_lane_false_control returning false AND native_lane_true_control passing, and gunbc.witness_v2_native_route native_route_live_pair_standing is set to LivePairRequired in the same change. Sufficient for: admission refusing any receipt whose evaluator does not discriminate. A resolver change that moves the pinned refusal to another cause does NOT retire this row; it re-pins it. From 851d939742c8443fc85cdecba8e7c5864ecef86c Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Tue, 22 Sep 2026 05:11:57 +0000 Subject: [PATCH 10/17] The XL-0 status arm stops citing the refusal this PR deletes, and names which conjunct of its capability is still missing review 69955. StageXL0Denominator's why asserted in the present tense that the resolver refuses the reference with resolve_reason_qualified_target_identity_unrepresentable. This PR deletes that reason and lands exactly the answer the arm named as its derivability condition, so the row cited a symbol with no producer and understated the landed rung (DESIGN 4b(1)). The stage stays NotDerivable and the instrument stays NotBuilt, which is the honest reading: ResolverBoundDeclaringIdentity is a two-conjunct capability and only the resolver conjunct is landed. The arm now says so and names the remaining conjunct - repair_input_origin_roster deriving declaring from the resolved tree's path rather than from resolve_reference_provenance's spelling rebuild. Co-Authored-By: Claude Opus 5 (1M context) --- dag/gunbc/compiler_frontend_program_status.dag | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dag/gunbc/compiler_frontend_program_status.dag b/dag/gunbc/compiler_frontend_program_status.dag index fbb8ec35b3c..ce77ccd939d 100644 --- a/dag/gunbc/compiler_frontend_program_status.dag +++ b/dag/gunbc/compiler_frontend_program_status.dag @@ -947,7 +947,7 @@ fn stage_status(s: NamespaceCutStage) -> MilestoneStanding { StageF0 => milestone_status(m: NamespaceWaveAdmissionEnrolled) StageXL0Denominator => NotDerivable { - why: "XL-0 is the REPAIR-INPUT ORIGIN DENOMINATOR: source-declaration carriers whose DECLARING identity is what answered each mention. The producer EXISTS and ANSWERS on the production route -- v2.compiler.repair_input_origin_roster repair_input_origin_roster_over_roots is total and non-empty over a production-ingested fixture and carries the cross-module mention (gunbc#11582; re-run rather than trust: v2.test.claim.namespace_xl0.cross_module_reference_resolution, v2.test.claim.namespace_xl0.call_argument_mention_survival). THIS ARM READ CLEAR OFF THAT FOR ONE HEAD AND THAT WAS RUNG INFLATION (review 67708): the roster's RepairInputDeclared.declaring is derived from the authored SPELLING (v2.compiler.resolution_provenance resolve_reference_provenance confirms the index hit and rebuilds the declaration from the path; carrier_declaration_ref drops an owner prefix), while the compiler resolver refuses the same reference resolve_reason_qualified_target_identity_unrepresentable because it carries no exact declaring identity. The roster module's own frontier row (repair_input_declared_binding_frontier_dissolve_on) says the spelling-derived identity becomes a second authority the moment the roster produces on the production route, which is now. A denominator whose declaring identities are spelled rather than bound is not the population this stage is defined by (DESIGN section 3, one authority; section 4b(1), the reported rung equals the executed evidence). Derivable when the resolver answers a qualified reference with an exact declaring identity by execution and the roster's `declaring` is derived from that binding; the collector-side capability (the dotted-reference lowering) is landed and is not what is missing. The base-pinned execution remains ACT-0's DenominatorRosterDigest either way." as NonEmptyStr, + why: "XL-0 is the REPAIR-INPUT ORIGIN DENOMINATOR: source-declaration carriers whose DECLARING identity is what answered each mention. The producer EXISTS and ANSWERS on the production route -- v2.compiler.repair_input_origin_roster repair_input_origin_roster_over_roots is total and non-empty over a production-ingested fixture and carries the cross-module mention (gunbc#11582; re-run rather than trust: v2.test.claim.namespace_xl0.cross_module_reference_resolution, v2.test.claim.namespace_xl0.call_argument_mention_survival). THIS ARM READ CLEAR OFF THAT FOR ONE HEAD AND THAT WAS RUNG INFLATION (review 67708): the roster's RepairInputDeclared.declaring is derived from the authored SPELLING (v2.compiler.resolution_provenance resolve_reference_provenance confirms the index hit and rebuilds the declaration from the path; carrier_declaration_ref drops an owner prefix), while the roster's own `declaring` is still that spelling. THE RESOLVER HALF OF THIS CAPABILITY LANDED (gunbc#12048, fierce-wren-487): v2.compiler.resolve now decides ResolvedReferenceIdentity = ResolvedToKernelSymbol or ResolvedToDeclaration carrying a path, and mints every module-level reference through resolved_reference_node, so a qualified reference IS answered with its exact declaring containment path by execution, and the interim refusal resolve_reason_qualified_target_identity_unrepresentable that this arm previously cited is DELETED -- it is no longer what holds XL-0 open. The roster module's own frontier row (repair_input_declared_binding_frontier_dissolve_on) says the spelling-derived identity becomes a second authority the moment the roster produces on the production route, which is now. A denominator whose declaring identities are spelled rather than bound is not the population this stage is defined by (DESIGN section 3, one authority; section 4b(1), the reported rung equals the executed evidence). ResolverBoundDeclaringIdentity is a TWO-CONJUNCT capability and only the first conjunct is landed, so the instrument stays NotBuilt and this arm stays NotDerivable: what remains is the SECOND conjunct -- v2.compiler.repair_input_origin_roster deriving RepairInputDeclared.declaring from the resolved tree's ResolvedToDeclaration path instead of from resolve_reference_provenance's spelling rebuild (carrier_declaration_ref). That is the dissolution the roster module's own frontier row already names. The collector-side capability (the dotted-reference lowering) is landed and is not what is missing either. The base-pinned execution remains ACT-0's DenominatorRosterDigest either way." as NonEmptyStr, cause: needs(i: ResolverBoundDeclaringIdentity) } StageXL1BaselineCapture => From 3bdadbf0eadbe5f5c900498791a6c0d0806a02c9 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Tue, 22 Sep 2026 05:36:20 +0000 Subject: [PATCH 11/17] The missing import row my own rule would have refused, and the warm row the floor asked for TWO FIXES, and the first is the one worth reading. 1. REVIEW 69963: src/v2/compiler/03_resolve.dag called symbol_index_absolute_candidates at two sites with no import row for it. Under the seed that resolves; under THE RULE THIS PR LANDS it does not -- a bare cross-module name with no chain binding and no transmuted import row is LexicalUnbound, then SymbolIndexAtomUnbound. So the resolver module was, by its own new rule, unresolvable when v2 ingests v2: exactly the class this package exists to remove, reintroduced on a line the package added. The cause is worth recording because it is mechanical: the edit that should have added the row was a string replace with no assertion, and it matched nothing. A silent no-op in the one file where the consequence is a latent break in the corpus's own compiler. I then scanned every line this diff ADDS in production modules for the same class -- called identifiers absent from both the local declarations and the import block -- and this was the only real instance; the two remaining hits were false positives with no such call on any added line. Worth stating what the scan also found, because it is not mine to fix: a number of corpus modules carry NO import block at all and resolve today only because the seed searches globally. v2.test.claim.name_resolve.test_code_reference_wall is one. That is the population the namespace cut has to migrate, and it is what a corpus-wide census measures; it is not a regression this PR introduces. 2. THE FLOOR REFUSED THIS PR'S OWN WITNESS: nine identities at 941,902 eval steps against a 72,300 new-witness budget, cause=completed_over_cost_requirement. The harness I modelled this file on carries the answer in its own annotation -- STOP RECOMPUTING, DO NOT RAISE THE LINE -- and I followed the shape without completing it: ncr_outcomes_warm was a pure nullary producer but was never enrolled, so every claim rebuilt it, and a third front-end run (ncr_context) sat outside it entirely. The oracle readings now come from the context the warm producer already built, so the third run is gone, and the row is enrolled in v2.workflow.floor_pure_producer_share floor_cross_claim_pure_producers_warm with the arithmetic stated: the fill is two front ends over twelve modules -- two because the file-order claim's whole content is that the two orders agree, and a second front end is the only way to have two orders -- and the alternative is paying it nine times. 9/9, and the oracle arm's mutation control re-run after the restructure: restoring the census write still reds it and nothing else. Co-Authored-By: Claude Opus 5 (1M context) --- src/v2/compiler/03_resolve.dag | 1 + .../resolve/namespace_candidate_rule_test.dag | 71 ++++++++++--------- src/v2/workflow/floor_pure_producer_share.dag | 10 +++ 3 files changed, 49 insertions(+), 33 deletions(-) diff --git a/src/v2/compiler/03_resolve.dag b/src/v2/compiler/03_resolve.dag index c62ebaf5dc5..fdca28c27c0 100644 --- a/src/v2/compiler/03_resolve.dag +++ b/src/v2/compiler/03_resolve.dag @@ -70,6 +70,7 @@ import v2.std.symbol_index { LexicalUnbound, SymbolIndex, empty_symbol_index, + symbol_index_absolute_candidates, symbol_index_lexical_lookup, symbol_index_lookup } diff --git a/src/v2/test/claim/resolve/namespace_candidate_rule_test.dag b/src/v2/test/claim/resolve/namespace_candidate_rule_test.dag index 53cceb03644..350634b3ef4 100644 --- a/src/v2/test/claim/resolve/namespace_candidate_rule_test.dag +++ b/src/v2/test/claim/resolve/namespace_candidate_rule_test.dag @@ -44,7 +44,6 @@ import v2.std.symbol_index { GlobalBareLookupUnbound, symbol_index_global_unique_lookup } -import v2.compiler.name_resolve { ResolutionContext } import v2.std.text { String } data live_tree_disposition: LiveTreeDisposition = SubstrateInputsOnly @@ -382,6 +381,8 @@ type NcrOutcomes shadow_permuted: NcrVerdict chain_permuted: NcrVerdict qual_permuted: NcrVerdict + singly_declared_leaf_is_unique: Bool + doubly_declared_leaf_is_ambiguous: Bool } fn ncr_verdicts_for(ingest: SourceRootIngest, module_name: String) -> Optional { @@ -397,17 +398,6 @@ fn ncr_verdicts_for(ingest: SourceRootIngest, module_name: String) -> Optional Optional { - match native_test_context_from_ingest(ingest: ncr_ingest) { - Rejected { diagnostics: _ } => optional_absent() - Accepted { value: ctx, diagnostics: _ } => - match ctx.resolution { - Rejected { diagnostics: _ } => optional_absent() - Accepted { value: rc, diagnostics: _ } => optional_present(value: rc) - } - } -} - fn ncr_outcomes() -> NcrOutcomes { match native_test_context_from_ingest(ingest: ncr_ingest) { Rejected { diagnostics: _ } => NcrContextRefused @@ -418,6 +408,24 @@ fn ncr_outcomes() -> NcrOutcomes { Accepted { value: permuted_context, diagnostics: _ } => { let permuted_index = native_lane_file_refusal_index(refusals: permuted_context.file_refusals) NcrOutcomesDecided { + singly_declared_leaf_is_unique: (match context.resolution { + Rejected { diagnostics: _ } => false + Accepted { value: rc, diagnostics: _ } => + match symbol_index_global_unique_lookup(index: rc.symbol_index, name: ^NcrDisp) { + GlobalBareHit { node: _, path: _ } => true + GlobalBareLookupAmbiguous => false + GlobalBareLookupUnbound => false + } + }), + doubly_declared_leaf_is_ambiguous: (match context.resolution { + Rejected { diagnostics: _ } => false + Accepted { value: rc, diagnostics: _ } => + match symbol_index_global_unique_lookup(index: rc.symbol_index, name: ^NcrTwin) { + GlobalBareHit { node: _, path: _ } => false + GlobalBareLookupAmbiguous => true + GlobalBareLookupUnbound => false + } + }), importer: ncr_verdict_of(outcome: ncr_outcome_in(context: context, index: index, ingest: ncr_ingest, module_name: "v2.ncr_importer")), double: ncr_verdict_of(outcome: ncr_outcome_in(context: context, index: index, ingest: ncr_ingest, module_name: "v2.ncr_double")), unbound: ncr_verdict_of(outcome: ncr_outcome_in(context: context, index: index, ingest: ncr_ingest, module_name: "v2.ncr_unbound")), @@ -510,7 +518,7 @@ fn ncr_verdict_same(left: NcrVerdict, right: NcrVerdict) -> Bool { test fn a_bound_sibling_declaration_resolves_by_containment_holds() -> Bool { match ncr_outcomes_warm { NcrContextRefused => false - NcrOutcomesDecided { importer: importer, double: _, unbound: _, home_a: home_a, disp_user: _, shadow: _, chain: _, qual: _, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _, chain_permuted: _, qual_permuted: _ } => + NcrOutcomesDecided { importer: importer, double: _, unbound: _, home_a: home_a, disp_user: _, shadow: _, chain: _, qual: _, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _, chain_permuted: _, qual_permuted: _, singly_declared_leaf_is_unique: _, doubly_declared_leaf_is_ambiguous: _ } => ncr_verdict_is_resolved(v: importer) && ncr_verdict_is_resolved(v: home_a) } } @@ -521,7 +529,7 @@ test fn a_bound_sibling_declaration_resolves_by_containment_holds() -> Bool { test fn a_genuine_ambiguity_refuses_naming_both_binders_holds() -> Bool { match ncr_outcomes_warm { NcrContextRefused => false - NcrOutcomesDecided { importer: _, double: double, unbound: _, home_a: _, disp_user: _, shadow: _, chain: _, qual: _, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _, chain_permuted: _, qual_permuted: _ } => + NcrOutcomesDecided { importer: _, double: double, unbound: _, home_a: _, disp_user: _, shadow: _, chain: _, qual: _, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _, chain_permuted: _, qual_permuted: _, singly_declared_leaf_is_unique: _, doubly_declared_leaf_is_ambiguous: _ } => ncr_verdict_refuses_with(v: double, reason: ^resolve_reason_ambiguous_symbol) && ncr_verdict_names(v: double, dotted: "v2.ncr_home_a.NcrTwin") && ncr_verdict_names(v: double, dotted: "v2.ncr_home_b.NcrTwin") @@ -537,7 +545,7 @@ test fn a_genuine_ambiguity_refuses_naming_both_binders_holds() -> Bool { test fn an_unbound_name_the_corpus_spells_twice_stays_unbound_holds() -> Bool { match ncr_outcomes_warm { NcrContextRefused => false - NcrOutcomesDecided { importer: _, double: _, unbound: unbound, home_a: _, disp_user: _, shadow: _, chain: _, qual: _, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _, chain_permuted: _, qual_permuted: _ } => + NcrOutcomesDecided { importer: _, double: _, unbound: unbound, home_a: _, disp_user: _, shadow: _, chain: _, qual: _, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _, chain_permuted: _, qual_permuted: _, singly_declared_leaf_is_unique: _, doubly_declared_leaf_is_ambiguous: _ } => ncr_verdict_refuses_with(v: unbound, reason: ^resolve_reason_unbound_symbol) } } @@ -565,7 +573,9 @@ test fn reordering_the_files_changes_no_verdict_holds() -> Bool { disp_user_permuted: disp_user_permuted, shadow_permuted: shadow_permuted, chain_permuted: chain_permuted, - qual_permuted: qual_permuted + qual_permuted: qual_permuted, + singly_declared_leaf_is_unique: _, + doubly_declared_leaf_is_ambiguous: _ } => ncr_verdict_same(left: importer, right: importer_permuted) && ncr_verdict_same(left: double, right: double_permuted) @@ -587,7 +597,7 @@ test fn reordering_the_files_changes_no_verdict_holds() -> Bool { test fn the_workload_bare_variant_reference_resolves_through_its_import_holds() -> Bool { match ncr_outcomes_warm { NcrContextRefused => false - NcrOutcomesDecided { importer: _, double: _, unbound: _, home_a: _, disp_user: disp_user, shadow: _, chain: _, qual: _, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _, chain_permuted: _, qual_permuted: _ } => + NcrOutcomesDecided { importer: _, double: _, unbound: _, home_a: _, disp_user: disp_user, shadow: _, chain: _, qual: _, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _, chain_permuted: _, qual_permuted: _, singly_declared_leaf_is_unique: _, doubly_declared_leaf_is_ambiguous: _ } => ncr_verdict_is_resolved(v: disp_user) } } @@ -599,7 +609,7 @@ test fn the_workload_bare_variant_reference_resolves_through_its_import_holds() test fn declaring_and_importing_one_name_refuses_naming_both_holds() -> Bool { match ncr_outcomes_warm { NcrContextRefused => false - NcrOutcomesDecided { importer: _, double: _, unbound: _, home_a: _, disp_user: _, shadow: shadow, chain: _, qual: _, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _, chain_permuted: _, qual_permuted: _ } => + NcrOutcomesDecided { importer: _, double: _, unbound: _, home_a: _, disp_user: _, shadow: shadow, chain: _, qual: _, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _, chain_permuted: _, qual_permuted: _, singly_declared_leaf_is_unique: _, doubly_declared_leaf_is_ambiguous: _ } => ncr_verdict_refuses_with(v: shadow, reason: ^resolve_reason_ambiguous_symbol) && ncr_verdict_names(v: shadow, dotted: "v2.ncr_shadow.NcrTwin") && ncr_verdict_names(v: shadow, dotted: "v2.ncr_home_a.NcrTwin") @@ -616,7 +626,7 @@ test fn declaring_and_importing_one_name_refuses_naming_both_holds() -> Bool { test fn a_re_export_chain_resolves_through_its_relay_holds() -> Bool { match ncr_outcomes_warm { NcrContextRefused => false - NcrOutcomesDecided { importer: _, double: _, unbound: _, home_a: _, disp_user: _, shadow: _, chain: chain, qual: _, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _, chain_permuted: _, qual_permuted: _ } => + NcrOutcomesDecided { importer: _, double: _, unbound: _, home_a: _, disp_user: _, shadow: _, chain: chain, qual: _, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _, chain_permuted: _, qual_permuted: _, singly_declared_leaf_is_unique: _, doubly_declared_leaf_is_ambiguous: _ } => ncr_verdict_is_resolved(v: chain) } } @@ -628,7 +638,7 @@ test fn a_re_export_chain_resolves_through_its_relay_holds() -> Bool { test fn a_qualified_reference_to_a_contested_position_refuses_naming_both_holds() -> Bool { match ncr_outcomes_warm { NcrContextRefused => false - NcrOutcomesDecided { importer: _, double: _, unbound: _, home_a: _, disp_user: _, shadow: _, chain: _, qual: qual, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _, chain_permuted: _, qual_permuted: _ } => + NcrOutcomesDecided { importer: _, double: _, unbound: _, home_a: _, disp_user: _, shadow: _, chain: _, qual: qual, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _, chain_permuted: _, qual_permuted: _, singly_declared_leaf_is_unique: _, doubly_declared_leaf_is_ambiguous: _ } => ncr_verdict_refuses_with(v: qual, reason: ^resolve_reason_ambiguous_symbol) && ncr_verdict_names(v: qual, dotted: "v2.ncr_shadow.NcrTwin") && ncr_verdict_names(v: qual, dotted: "v2.ncr_home_a.NcrTwin") @@ -654,18 +664,13 @@ test fn a_qualified_reference_to_a_contested_position_refuses_naming_both_holds( // bare atom in a match arm names a constructor, and reads Ambiguous as yes. Every leaf polluted // this way would push a fresh arm BINDER toward being classified as a constructor and refused. test fn a_transmuted_import_does_not_make_its_leaf_globally_ambiguous_holds() -> Bool { - match ncr_context() { - Absent => false - Present { value: rc } => - (match symbol_index_global_unique_lookup(index: rc.symbol_index, name: ^NcrDisp) { - GlobalBareHit { node: _, path: _ } => true - GlobalBareLookupAmbiguous => false - GlobalBareLookupUnbound => false - }) - && (match symbol_index_global_unique_lookup(index: rc.symbol_index, name: ^NcrTwin) { - GlobalBareHit { node: _, path: _ } => false - GlobalBareLookupAmbiguous => true - GlobalBareLookupUnbound => false - }) + match ncr_outcomes_warm { + NcrContextRefused => false + NcrOutcomesDecided { + importer: _, double: _, unbound: _, home_a: _, disp_user: _, shadow: _, chain: _, qual: _, + importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, + shadow_permuted: _, chain_permuted: _, qual_permuted: _, + singly_declared_leaf_is_unique: unique, doubly_declared_leaf_is_ambiguous: ambiguous + } => unique && ambiguous } } diff --git a/src/v2/workflow/floor_pure_producer_share.dag b/src/v2/workflow/floor_pure_producer_share.dag index cdaf80683c4..a7f760166f5 100644 --- a/src/v2/workflow/floor_pure_producer_share.dag +++ b/src/v2/workflow/floor_pure_producer_share.dag @@ -575,7 +575,17 @@ import v2.std.collection { List } // THE CLI TRAILING-TOKEN PROBE ROW: v2.test.cli.v2_native_cli drives v2_cli_run over a two-file // supplied ingest whose second file refuses at parse; two claims read one field each of the // CliRunOutcome. Stored value is a refused outcome (a Symbol and a String), portable. +// THE NAMESPACE CANDIDATE RULE PROBE ROW IS THE SAME GROUND AGAIN, and the most expensive instance +// of it: v2.test.claim.resolve.namespace_candidate_rule drives native_test_context_from_ingest over +// a TWELVE-specimen supplied ingest TWICE -- once in declared order and once permuted -- because the +// file-order claim's whole content is that the two orders agree, and a second front end is the only +// way to have two orders. Nine claims then read one field each of the stored value. The stored value +// is verdict arms plus two Bool oracle readings (reason symbols, competing declaration paths, counts): +// no resolution context and no closure, portable for the same reason census_probe_outcomes is. WARM +// because the fill is two front ends over twelve modules, many times one claim's budget, and the +// alternative is paying it nine times. data floor_cross_claim_pure_producers_warm: List = [ + "v2.test.claim.resolve.namespace_candidate_rule.ncr_outcomes_warm", "v2.test.cli.v2_native_cli.cli_probe_trailing_outcome", "v2.test.claim.native_route.native_refusal_detail.ndp_resolutions", "v2.test.claim.native_census.whole_tree_census_resolve.census_probe_outcomes", From 05d739422c708cf6d2e7df3603cb54b93ba30590 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Tue, 22 Sep 2026 05:40:20 +0000 Subject: [PATCH 12/17] A missing binding refuses with evidence too, not just a location The manager asked, on the strength of an independent specimen, whether an unimported name refuses with a LOCATED cause naming the missing import or a generic not-found. I measured: located at the reference occurrence, but GENERIC -- reason resolve_reason_unbound_symbol, correction Unavailable { UserInputBoundary }. It named the symbol and the site and said nothing about a missing binding. That is a gap against this package's own brief, which asks that MISSING and competing bindings both refuse with evidence. Competing had it -- lookup class plus both binders at a DeclarationLocus -- and missing did not. The specimen is why it matters. calm-koi-296, on gunbc#12035 and with no knowledge of this work, found a production reference resolving with NOTHING importing it, confirmed by a mutation that reds. Every module quietly relying on the corpus-wide search starts refusing when the search is deleted, so that population is real and it gets whatever this refusal says. THE ORACLE IS THE RIGHT SOURCE FOR THIS AND THE WRONG SOURCE FOR RESOLUTION, which is the distinction section 13 already draws when it keeps global_bare as a migration oracle after deleting it as a mechanism. Asking the corpus "does this spelling exist anywhere" may not CHOOSE a meaning -- that is the defect this package removes -- but it is exactly the right question for explaining an absence. So an unbound reference now carries an advisory in front of the fatal: resolve_unbound_name_is_declared_elsewhere at the declaring path when the corpus declares the name exactly once, resolve_unbound_name_is_declared_in_several_modules when more than one does, and NOTHING when no module declares it -- because there the honest reading is a typo and pointing anywhere would be fabrication. Two arms, and the second is what stops the first being satisfied wrongly: - a_missing_binding_names_where_the_name_is_declared: v2.ncr_missing_import names NcrDisp, declared exactly once, and the chain carries the declaring path. - a_missing_binding_the_corpus_declares_twice_names_no_single_path: v2.ncr_unbound names NcrTwin, which two modules declare, so the advisory says several and carries NO path. Without it, an implementation that always pointed at whichever declaration it found first would pass -- the spelling search readmitted as a diagnostic. The fatal is unchanged in both: the advisory explains the absence, it does not change what the refusal IS. 11/11. Co-Authored-By: Claude Opus 5 (1M context) --- src/v2/compiler/03_resolve.dag | 51 ++++++- .../resolve/namespace_candidate_rule_test.dag | 135 +++++++++++++++--- 2 files changed, 166 insertions(+), 20 deletions(-) diff --git a/src/v2/compiler/03_resolve.dag b/src/v2/compiler/03_resolve.dag index fdca28c27c0..cffa1477112 100644 --- a/src/v2/compiler/03_resolve.dag +++ b/src/v2/compiler/03_resolve.dag @@ -70,7 +70,11 @@ import v2.std.symbol_index { LexicalUnbound, SymbolIndex, empty_symbol_index, + GlobalBareHit, + GlobalBareLookupAmbiguous, + GlobalBareLookupUnbound, symbol_index_absolute_candidates, + symbol_index_global_unique_lookup, symbol_index_lexical_lookup, symbol_index_lookup } @@ -399,6 +403,51 @@ fn unbound_symbol_diagnostic(n: Node) -> Diagnostic { } } +// AN UNBOUND NAME THE CORPUS DOES DECLARE IS A MISSING BINDING, NOT A TYPO, AND THE REFUSAL SAYS SO. +// Deleting the corpus-wide spelling search makes a reference refuse when nothing on its chain binds +// it -- correct, and the point of the package -- but on its own it hands the author of a module +// that was quietly relying on that search a bare "unbound symbol here". That population is not +// hypothetical: a lane with no knowledge of this work (gunbc#12035) found a production reference +// resolving with nothing importing it, confirmed by a mutation that reds. Every such module starts +// refusing when the search goes, and the difference between a diagnostic they can act on and a +// puzzle is whether the refusal names where the name IS declared. +// +// THE ORACLE IS THE RIGHT SOURCE FOR THIS AND THE WRONG SOURCE FOR RESOLUTION, which is the whole +// distinction docs/plans/namespace-resolution-design.md section 13 draws when it keeps global_bare +// as a migration oracle after deleting it as a mechanism. Asking the corpus "does this spelling +// exist anywhere" may not CHOOSE a meaning -- that is the defect -- but it is exactly the right +// question for explaining an absence. So the advisory rides in front and the fatal stays last, the +// carriage order every consumer folds to; the advisory carries a DeclarationLocus so the author is +// pointed at a declaration rather than a spelling. +// +// IT DISCRIMINATES THE TWO CAUSES, which is why the third arm exists rather than defaulting: a name +// no module declares gets NO advisory, because the honest reading there is a typo or a deleted +// declaration and pointing anywhere would be fabrication. +fn unbound_symbol_diagnostics(ctx: ResolveContext, n: Node, name: Symbol) -> NonEmptyDiagnostics { + let fatal = unbound_symbol_diagnostic(n: n) + match symbol_index_global_unique_lookup(index: ctx.namespace.symbol_index, name: name) { + GlobalBareHit { node: _, path: path } => + NonEmptyDiagnostics { + head: Diagnostic { + reason: ^resolve_unbound_name_is_declared_elsewhere, + at: declaration_locus(declaration: path), + correction: Unavailable { reason: UserInputBoundary } + }, + tail: [fatal] + } + GlobalBareLookupAmbiguous => + NonEmptyDiagnostics { + head: Diagnostic { + reason: ^resolve_unbound_name_is_declared_in_several_modules, + at: node_locus(node: n), + correction: Unavailable { reason: UserInputBoundary } + }, + tail: [fatal] + } + GlobalBareLookupUnbound => NonEmptyDiagnostics { head: fatal, tail: [] } + } +} + // A QUALIFIED HIT ON A NON-ATOM DECLARATION HAS NO IDENTITY THIS RESOLVER CAN CARRY, SO IT REFUSES. // The resolved value here is a canonical Atom -- one Symbol -- and a fn (an Arrow) or a record // found by its full path is a DECLARATION, not a symbol: collapsing it to its leaf name would make @@ -818,7 +867,7 @@ fn resolve_atom(ctx: ResolveContext, n: Node, identity: Symbol) -> Outcome Rejected { diagnostics: rejected_with_pending( pending: pending, - rejected: diagnostics_singleton(d: unbound_symbol_diagnostic(n: n)) + rejected: unbound_symbol_diagnostics(ctx: ctx, n: n, name: identity) ) } } diff --git a/src/v2/test/claim/resolve/namespace_candidate_rule_test.dag b/src/v2/test/claim/resolve/namespace_candidate_rule_test.dag index 350634b3ef4..3b709369538 100644 --- a/src/v2/test/claim/resolve/namespace_candidate_rule_test.dag +++ b/src/v2/test/claim/resolve/namespace_candidate_rule_test.dag @@ -155,6 +155,13 @@ data ncr_chain_source: String = "module v2.ncr_chain\n\nimport v2.std.logic { Bo // answers decided by how the reference was spelled. data ncr_qual_source: String = "module v2.ncr_qual\n\nimport v2.std.logic { Bool }\n\nfn ncr_qual_use(p: v2.ncr_shadow.NcrTwin) -> Bool { p }\n" +// THE MIGRATION POPULATION'S DIAGNOSTIC, at fixture scale. This module names `NcrDisp` and imports +// it from nowhere -- the shape every module that was quietly relying on the corpus-wide spelling +// search turns into when that search is deleted. `NcrDisp` is declared EXACTLY ONCE in the ingest, +// so the refusal can name where it is declared; v2.ncr_unbound is the companion, naming `NcrTwin`, +// which two modules declare, so there the refusal must say "several" and name no single path. +data ncr_missing_import_source: String = "module v2.ncr_missing_import\n\nimport v2.std.logic { Bool }\n\nfn ncr_missing(p: NcrDisp) -> Bool { p }\n" + fn ncr_read(source: String, id: Symbol, unit: Symbol, path: String) -> DagSourceReadWitness { DagSourceReadWitness { source: Medium { carried: source, fidelity: Lossless }, @@ -164,6 +171,10 @@ fn ncr_read(source: String, id: Symbol, unit: Symbol, path: String) -> DagSource } } +fn ncr_missing_import_read() -> DagSourceReadWitness { + ncr_read(source: ncr_missing_import_source, id: ^ncr_missing_import_artifact, unit: ^ncr_missing_import_cu, path: "dag/ncr_missing_import.dag") +} + fn ncr_qual_read() -> DagSourceReadWitness { ncr_read(source: ncr_qual_source, id: ^ncr_qual_artifact, unit: ^ncr_qual_cu, path: "dag/ncr_qual.dag") } @@ -237,7 +248,10 @@ data ncr_ingest: SourceRootIngest = Cons { head: ncr_relay_read(), tail: Cons { head: ncr_chain_read(), - tail: Cons { head: ncr_qual_read(), tail: Empty } + tail: Cons { + head: ncr_qual_read(), + tail: Cons { head: ncr_missing_import_read(), tail: Empty } + } } } } @@ -272,7 +286,10 @@ data ncr_ingest_permuted: SourceRootIngest = Cons { head: ncr_chain_read(), tail: Cons { head: ncr_relay_read(), - tail: Cons { head: ncr_qual_read(), tail: Empty } + tail: Cons { + head: ncr_qual_read(), + tail: Cons { head: ncr_missing_import_read(), tail: Empty } + } } } } @@ -317,7 +334,7 @@ fn ncr_outcome_in( // competing declarations the ambiguity named beside it. type NcrVerdict = NcrResolved - | NcrRefused { reason: Symbol, competing: FreeMonoid } + | NcrRefused { reason: Symbol, competing: FreeMonoid, reasons: FreeMonoid, declared_at: FreeMonoid } | NcrFileRefused | NcrAbsent @@ -351,7 +368,27 @@ fn ncr_verdict_of(outcome: Optional) -> NcrVerdict { NativeCensusModuleResolveRefused { diagnostics: d } => NcrRefused { reason: diagnostics_fatal_reason(d: d), - competing: ncr_competing_declarations(d: d) + competing: ncr_competing_declarations(d: d), + reasons: fold_list( + xs: Cons { head: d.head, tail: d.tail }, + empty: Empty, + cons: fn(acc, diag) { Cons { head: diag.reason, tail: acc } } + ), + declared_at: fold_list( + xs: Cons { head: d.head, tail: d.tail }, + empty: Empty, + cons: fn(acc, diag) { + if diag.reason == ^resolve_unbound_name_is_declared_elsewhere { + match diag.at { + DeclarationLocus { declaration: path } => Cons { head: path, tail: acc } + Textual { file: _, extent: _ } => acc + NodeLocus { anchor: _ } => acc + SourcePortLocus { anchor: _, file: _, extent: _ } => acc + InvariantPortLocus { anchor: _, invariant: _ } => acc + } + } else { acc } + } + ) } } } @@ -381,6 +418,7 @@ type NcrOutcomes shadow_permuted: NcrVerdict chain_permuted: NcrVerdict qual_permuted: NcrVerdict + missing_import: NcrVerdict singly_declared_leaf_is_unique: Bool doubly_declared_leaf_is_ambiguous: Bool } @@ -426,6 +464,7 @@ fn ncr_outcomes() -> NcrOutcomes { GlobalBareLookupUnbound => false } }), + missing_import: ncr_verdict_of(outcome: ncr_outcome_in(context: context, index: index, ingest: ncr_ingest, module_name: "v2.ncr_missing_import")), importer: ncr_verdict_of(outcome: ncr_outcome_in(context: context, index: index, ingest: ncr_ingest, module_name: "v2.ncr_importer")), double: ncr_verdict_of(outcome: ncr_outcome_in(context: context, index: index, ingest: ncr_ingest, module_name: "v2.ncr_double")), unbound: ncr_verdict_of(outcome: ncr_outcome_in(context: context, index: index, ingest: ncr_ingest, module_name: "v2.ncr_unbound")), @@ -453,7 +492,7 @@ data ncr_outcomes_warm: NcrOutcomes = ncr_outcomes() fn ncr_verdict_is_resolved(v: NcrVerdict) -> Bool { match v { NcrResolved => true - NcrRefused { reason: _, competing: _ } => false + NcrRefused { reason: _, competing: _, reasons: _, declared_at: _ } => false NcrFileRefused => false NcrAbsent => false } @@ -462,7 +501,7 @@ fn ncr_verdict_is_resolved(v: NcrVerdict) -> Bool { fn ncr_verdict_refuses_with(v: NcrVerdict, reason: Symbol) -> Bool { match v { NcrResolved => false - NcrRefused { reason: r, competing: _ } => r == reason + NcrRefused { reason: r, competing: _, reasons: _, declared_at: _ } => r == reason NcrFileRefused => false NcrAbsent => false } @@ -471,7 +510,7 @@ fn ncr_verdict_refuses_with(v: NcrVerdict, reason: Symbol) -> Bool { fn ncr_verdict_names(v: NcrVerdict, dotted: String) -> Bool { match v { NcrResolved => false - NcrRefused { reason: _, competing: competing } => + NcrRefused { reason: _, competing: competing, reasons: _, declared_at: _ } => contains( xs: competing, item: qualified_name_from_dotted_string(dotted: dotted), @@ -485,7 +524,7 @@ fn ncr_verdict_names(v: NcrVerdict, dotted: String) -> Bool { fn ncr_verdict_competing_count(v: NcrVerdict) -> Int { match v { NcrResolved => 0 - NcrRefused { reason: _, competing: competing } => length(xs: competing) + NcrRefused { reason: _, competing: competing, reasons: _, declared_at: _ } => length(xs: competing) NcrFileRefused => 0 NcrAbsent => 0 } @@ -494,14 +533,14 @@ fn ncr_verdict_competing_count(v: NcrVerdict) -> Int { fn ncr_verdict_same(left: NcrVerdict, right: NcrVerdict) -> Bool { match left { NcrResolved => ncr_verdict_is_resolved(v: right) - NcrFileRefused => match right { NcrFileRefused => true, NcrResolved => false, NcrRefused { reason: _, competing: _ } => false, NcrAbsent => false } - NcrAbsent => match right { NcrAbsent => true, NcrResolved => false, NcrRefused { reason: _, competing: _ } => false, NcrFileRefused => false } - NcrRefused { reason: r, competing: c } => + NcrFileRefused => match right { NcrFileRefused => true, NcrResolved => false, NcrRefused { reason: _, competing: _, reasons: _, declared_at: _ } => false, NcrAbsent => false } + NcrAbsent => match right { NcrAbsent => true, NcrResolved => false, NcrRefused { reason: _, competing: _, reasons: _, declared_at: _ } => false, NcrFileRefused => false } + NcrRefused { reason: r, competing: c, reasons: _, declared_at: _ } => ncr_verdict_refuses_with(v: right, reason: r) && ncr_verdict_competing_count(v: right) == length(xs: c) && for_all(xs: c, predicate: fn(path) { match right { - NcrRefused { reason: _, competing: rc } => + NcrRefused { reason: _, competing: rc, reasons: _, declared_at: _ } => contains(xs: rc, item: path, eq: fn(a, b) { a == b }) NcrResolved => false NcrFileRefused => false @@ -518,7 +557,7 @@ fn ncr_verdict_same(left: NcrVerdict, right: NcrVerdict) -> Bool { test fn a_bound_sibling_declaration_resolves_by_containment_holds() -> Bool { match ncr_outcomes_warm { NcrContextRefused => false - NcrOutcomesDecided { importer: importer, double: _, unbound: _, home_a: home_a, disp_user: _, shadow: _, chain: _, qual: _, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _, chain_permuted: _, qual_permuted: _, singly_declared_leaf_is_unique: _, doubly_declared_leaf_is_ambiguous: _ } => + NcrOutcomesDecided { importer: importer, double: _, unbound: _, home_a: home_a, disp_user: _, shadow: _, chain: _, qual: _, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _, chain_permuted: _, qual_permuted: _, missing_import: _, singly_declared_leaf_is_unique: _, doubly_declared_leaf_is_ambiguous: _ } => ncr_verdict_is_resolved(v: importer) && ncr_verdict_is_resolved(v: home_a) } } @@ -529,7 +568,7 @@ test fn a_bound_sibling_declaration_resolves_by_containment_holds() -> Bool { test fn a_genuine_ambiguity_refuses_naming_both_binders_holds() -> Bool { match ncr_outcomes_warm { NcrContextRefused => false - NcrOutcomesDecided { importer: _, double: double, unbound: _, home_a: _, disp_user: _, shadow: _, chain: _, qual: _, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _, chain_permuted: _, qual_permuted: _, singly_declared_leaf_is_unique: _, doubly_declared_leaf_is_ambiguous: _ } => + NcrOutcomesDecided { importer: _, double: double, unbound: _, home_a: _, disp_user: _, shadow: _, chain: _, qual: _, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _, chain_permuted: _, qual_permuted: _, missing_import: _, singly_declared_leaf_is_unique: _, doubly_declared_leaf_is_ambiguous: _ } => ncr_verdict_refuses_with(v: double, reason: ^resolve_reason_ambiguous_symbol) && ncr_verdict_names(v: double, dotted: "v2.ncr_home_a.NcrTwin") && ncr_verdict_names(v: double, dotted: "v2.ncr_home_b.NcrTwin") @@ -545,7 +584,7 @@ test fn a_genuine_ambiguity_refuses_naming_both_binders_holds() -> Bool { test fn an_unbound_name_the_corpus_spells_twice_stays_unbound_holds() -> Bool { match ncr_outcomes_warm { NcrContextRefused => false - NcrOutcomesDecided { importer: _, double: _, unbound: unbound, home_a: _, disp_user: _, shadow: _, chain: _, qual: _, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _, chain_permuted: _, qual_permuted: _, singly_declared_leaf_is_unique: _, doubly_declared_leaf_is_ambiguous: _ } => + NcrOutcomesDecided { importer: _, double: _, unbound: unbound, home_a: _, disp_user: _, shadow: _, chain: _, qual: _, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _, chain_permuted: _, qual_permuted: _, missing_import: _, singly_declared_leaf_is_unique: _, doubly_declared_leaf_is_ambiguous: _ } => ncr_verdict_refuses_with(v: unbound, reason: ^resolve_reason_unbound_symbol) } } @@ -574,6 +613,7 @@ test fn reordering_the_files_changes_no_verdict_holds() -> Bool { shadow_permuted: shadow_permuted, chain_permuted: chain_permuted, qual_permuted: qual_permuted, + missing_import: _, singly_declared_leaf_is_unique: _, doubly_declared_leaf_is_ambiguous: _ } => @@ -597,7 +637,7 @@ test fn reordering_the_files_changes_no_verdict_holds() -> Bool { test fn the_workload_bare_variant_reference_resolves_through_its_import_holds() -> Bool { match ncr_outcomes_warm { NcrContextRefused => false - NcrOutcomesDecided { importer: _, double: _, unbound: _, home_a: _, disp_user: disp_user, shadow: _, chain: _, qual: _, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _, chain_permuted: _, qual_permuted: _, singly_declared_leaf_is_unique: _, doubly_declared_leaf_is_ambiguous: _ } => + NcrOutcomesDecided { importer: _, double: _, unbound: _, home_a: _, disp_user: disp_user, shadow: _, chain: _, qual: _, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _, chain_permuted: _, qual_permuted: _, missing_import: _, singly_declared_leaf_is_unique: _, doubly_declared_leaf_is_ambiguous: _ } => ncr_verdict_is_resolved(v: disp_user) } } @@ -609,7 +649,7 @@ test fn the_workload_bare_variant_reference_resolves_through_its_import_holds() test fn declaring_and_importing_one_name_refuses_naming_both_holds() -> Bool { match ncr_outcomes_warm { NcrContextRefused => false - NcrOutcomesDecided { importer: _, double: _, unbound: _, home_a: _, disp_user: _, shadow: shadow, chain: _, qual: _, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _, chain_permuted: _, qual_permuted: _, singly_declared_leaf_is_unique: _, doubly_declared_leaf_is_ambiguous: _ } => + NcrOutcomesDecided { importer: _, double: _, unbound: _, home_a: _, disp_user: _, shadow: shadow, chain: _, qual: _, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _, chain_permuted: _, qual_permuted: _, missing_import: _, singly_declared_leaf_is_unique: _, doubly_declared_leaf_is_ambiguous: _ } => ncr_verdict_refuses_with(v: shadow, reason: ^resolve_reason_ambiguous_symbol) && ncr_verdict_names(v: shadow, dotted: "v2.ncr_shadow.NcrTwin") && ncr_verdict_names(v: shadow, dotted: "v2.ncr_home_a.NcrTwin") @@ -626,7 +666,7 @@ test fn declaring_and_importing_one_name_refuses_naming_both_holds() -> Bool { test fn a_re_export_chain_resolves_through_its_relay_holds() -> Bool { match ncr_outcomes_warm { NcrContextRefused => false - NcrOutcomesDecided { importer: _, double: _, unbound: _, home_a: _, disp_user: _, shadow: _, chain: chain, qual: _, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _, chain_permuted: _, qual_permuted: _, singly_declared_leaf_is_unique: _, doubly_declared_leaf_is_ambiguous: _ } => + NcrOutcomesDecided { importer: _, double: _, unbound: _, home_a: _, disp_user: _, shadow: _, chain: chain, qual: _, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _, chain_permuted: _, qual_permuted: _, missing_import: _, singly_declared_leaf_is_unique: _, doubly_declared_leaf_is_ambiguous: _ } => ncr_verdict_is_resolved(v: chain) } } @@ -638,7 +678,7 @@ test fn a_re_export_chain_resolves_through_its_relay_holds() -> Bool { test fn a_qualified_reference_to_a_contested_position_refuses_naming_both_holds() -> Bool { match ncr_outcomes_warm { NcrContextRefused => false - NcrOutcomesDecided { importer: _, double: _, unbound: _, home_a: _, disp_user: _, shadow: _, chain: _, qual: qual, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _, chain_permuted: _, qual_permuted: _, singly_declared_leaf_is_unique: _, doubly_declared_leaf_is_ambiguous: _ } => + NcrOutcomesDecided { importer: _, double: _, unbound: _, home_a: _, disp_user: _, shadow: _, chain: _, qual: qual, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _, chain_permuted: _, qual_permuted: _, missing_import: _, singly_declared_leaf_is_unique: _, doubly_declared_leaf_is_ambiguous: _ } => ncr_verdict_refuses_with(v: qual, reason: ^resolve_reason_ambiguous_symbol) && ncr_verdict_names(v: qual, dotted: "v2.ncr_shadow.NcrTwin") && ncr_verdict_names(v: qual, dotted: "v2.ncr_home_a.NcrTwin") @@ -670,7 +710,64 @@ test fn a_transmuted_import_does_not_make_its_leaf_globally_ambiguous_holds() -> importer: _, double: _, unbound: _, home_a: _, disp_user: _, shadow: _, chain: _, qual: _, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _, chain_permuted: _, qual_permuted: _, + missing_import: _, singly_declared_leaf_is_unique: unique, doubly_declared_leaf_is_ambiguous: ambiguous } => unique && ambiguous } } + +fn ncr_verdict_has_reason(v: NcrVerdict, reason: Symbol) -> Bool { + match v { + NcrResolved => false + NcrFileRefused => false + NcrAbsent => false + NcrRefused { reason: _, competing: _, reasons: reasons, declared_at: _ } => + contains(xs: reasons, item: reason, eq: fn(a, b) { a == b }) + } +} + +fn ncr_verdict_declared_at(v: NcrVerdict, dotted: String) -> Bool { + match v { + NcrResolved => false + NcrFileRefused => false + NcrAbsent => false + NcrRefused { reason: _, competing: _, reasons: _, declared_at: declared_at } => + contains( + xs: declared_at, + item: qualified_name_from_dotted_string(dotted: dotted), + eq: fn(a, b) { a == b } + ) + } +} + +// A MISSING BINDING REFUSES WITH EVIDENCE, NOT JUST A LOCATION. Deleting the corpus-wide spelling +// search turns every module that was quietly relying on it into a refusal, and the difference +// between a diagnostic its author can act on and a puzzle is whether the refusal says WHERE the +// name is declared. `v2.ncr_missing_import` names `NcrDisp` and imports it from nowhere; `NcrDisp` +// is declared exactly once, so the chain carries resolve_unbound_name_is_declared_elsewhere at the +// declaring path. The fatal stays resolve_reason_unbound_symbol -- the advisory explains the +// absence, it does not change what the refusal IS. +test fn a_missing_binding_names_where_the_name_is_declared_holds() -> Bool { + match ncr_outcomes_warm { + NcrContextRefused => false + NcrOutcomesDecided { importer: _, double: _, unbound: _, home_a: _, disp_user: _, shadow: _, chain: _, qual: _, missing_import: missing_import, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _, chain_permuted: _, qual_permuted: _, singly_declared_leaf_is_unique: _, doubly_declared_leaf_is_ambiguous: _ } => + ncr_verdict_refuses_with(v: missing_import, reason: ^resolve_reason_unbound_symbol) + && ncr_verdict_has_reason(v: missing_import, reason: ^resolve_unbound_name_is_declared_elsewhere) + && ncr_verdict_declared_at(v: missing_import, dotted: "v2.ncr_disp_home.NcrDisp") + } +} + +// AND IT DOES NOT FABRICATE ONE WHEN THERE ARE SEVERAL. `v2.ncr_unbound` names `NcrTwin`, which TWO +// modules declare, so no single declaring path is the answer: the advisory says several and carries +// NO path. Without this arm the one above would be satisfied by an implementation that always +// pointed at whichever declaration it found first -- which is the spelling search this package +// deletes, readmitted as a diagnostic. +test fn a_missing_binding_the_corpus_declares_twice_names_no_single_path_holds() -> Bool { + match ncr_outcomes_warm { + NcrContextRefused => false + NcrOutcomesDecided { importer: _, double: _, unbound: unbound, home_a: _, disp_user: _, shadow: _, chain: _, qual: _, missing_import: _, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _, chain_permuted: _, qual_permuted: _, singly_declared_leaf_is_unique: _, doubly_declared_leaf_is_ambiguous: _ } => + ncr_verdict_refuses_with(v: unbound, reason: ^resolve_reason_unbound_symbol) + && ncr_verdict_has_reason(v: unbound, reason: ^resolve_unbound_name_is_declared_in_several_modules) + && !ncr_verdict_has_reason(v: unbound, reason: ^resolve_unbound_name_is_declared_elsewhere) + } +} From d5b6279d0eb70dac36b8c47099dc4019d77b1279 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Tue, 22 Sep 2026 05:56:45 +0000 Subject: [PATCH 13/17] declaration_reference_path_optional: delete the dead length guard, and stop the annotation describing the unreachable arm as the operative one review 69981. The `if length(xs: qn) > 0` guard is unreachable. qualified_name_spine_shape_present demands count(root.children) == 2, so the zero-child Conj - the empty spine - never reaches the fold and answers Absent from the outer arm. And every spine the gate DOES admit reaches QnFoldDone through QnSpineHead, whose arm requires an Atom target and a Cons { head, tail: Empty } child, so an accepted fold always carries at least one segment. Behavior is unchanged; the annotation was the load-bearing part (DESIGN 4c: an annotation must not restate what the declaration structurally says, and this one described the dead arm as the one that answers). It now names where the empty spine is actually refused and why no length guard belongs here. Evidence on this head: cross_module_reference_resolution 7/7 PASS, translate.declaration_reference_form 4/4 PASS - the deletion changed nothing observable, which is what makes it dead rather than merely unreached. Co-Authored-By: Claude Opus 5 (1M context) --- src/v2/std/qualified_name.dag | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/v2/std/qualified_name.dag b/src/v2/std/qualified_name.dag index 89f4a4b5b83..52eba624ffe 100644 --- a/src/v2/std/qualified_name.dag +++ b/src/v2/std/qualified_name.dag @@ -311,14 +311,17 @@ fn qualified_name_spine_node(qn: QualifiedName, occurrence_id: NodeOccurrenceIde // the spine shape, so a bare Atom is never a reference and a consumer asking "is this node a // resolved reference to a corpus declaration" gets a decidable answer from the shape alone, before // it reads the Conj as a record. A one-segment spine IS a reference -- a declaration in a root that -// names no module has a one-segment path -- and only the empty spine, which is also the empty -// record, answers Absent. A well-formed spine whose fold refuses is a malformed node, not a -// reference, and also answers Absent so that the consumer's own arm decides it. +// names no module has a one-segment path -- so a reference is never narrowed to the multi-segment +// case. The empty spine, which is also the empty record, is refused by the shape gate itself (it +// has no children where the gate demands two) and answers Absent from the outer arm; every spine +// the gate admits reaches QnFoldDone through QnSpineHead, whose target must be an Atom, so an +// accepted fold carries at least one segment and a length guard here would be dead. A well-formed +// spine whose fold refuses is a malformed node, not a reference, and also answers Absent so that +// the consumer's own arm decides it. fn declaration_reference_path_optional(node: Node) -> Optional { if qualified_name_spine_shape_present(root: node) { match qualified_name_from_node(root: node) { - Accepted { value: qn, diagnostics: _ } => - if length(xs: qn) > 0 { optional_present(value: qn) } else { optional_absent() } + Accepted { value: qn, diagnostics: _ } => optional_present(value: qn) Rejected { diagnostics: _ } => optional_absent() } } else { From 127e9335e1c9bfb9d941c549b525b946aa9dacac Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Tue, 22 Sep 2026 06:32:16 +0000 Subject: [PATCH 14/17] The instruments resolve the corpus the way production does, and the warm producer is a fn so the serve reaches it TWO FIXES. 1. REVIEW 69997: the census and the repair-input roster built their index through the node-only door -- whose own comment says it fills "no import bindings" -- while production fills from normalized_tree_roots_to_binding_sources. That was harmless while the corpus-wide spelling search was a resolution tier. THIS PR DELETES THE TIER and replaces it with exactly those bindings, so every cross-module reference production BINDS would have been reported unbound by the instrument. It is not an idle instrument. The roster's digest is what gunbc.namespace_cut_stage consumes as DenominatorRosterDigest, so the effect would have been to inflate the very denominator the namespace cut is defined by, with nothing red to say so. Fixed at the DOOR, not the call sites: both producers now take ModuleBindingSource, so a caller cannot hold roots and bindings separately and pair them by position. Of the three call sites, two already held NormalizedTrees and threw the bindings away one line before the call; the third builds Nodes by hand and now says so through module_binding_sources_without_imports -- named rather than a bare Empty, for the same reason no_import_bindings() is. This is the third defect in this package of one shape: a previously harmless approximation became load-bearing when the fallback was deleted. Node equality, the global-bare census write, and now the instrument's index. 2. THE FLOOR REFUSED AGAIN AND THE ENROLMENT WAS THE WRONG SHAPE. The warm row DID store -- [floor-phase] pure-producer-share-warm disposition=Stored cpu_ms=2370 provenance=built-by-preparation -- and the eleven claims still paid ~1,005,000 evaluator steps each. The store happened and nothing was served from it. The cause is the shape of the enrolled declaration. Both admitted precedents (census_probe_outcomes, ndp_resolutions) are nullary FNs that claims CALL; I enrolled a `data` row wrapping the producer and had the claims read the row. Enrolling `ncr_outcomes` and calling it is what makes the serve reach the consumer. Recorded on the roster entry with the measurement, because "it stored but was not served" is not a distinction the roster's prose made anywhere and the next author will hit it. 11/11 controls; the three instruments touched by (1) re-run at 7, 11 and 16 -- correcting the index changed none of their answers, which is the result that says the instruments were measuring reachable-but-equal populations rather than silently disagreeing already. Co-Authored-By: Claude Opus 5 (1M context) --- .../declaring_identity_spelling_census.dag | 20 +++++++++---- .../compiler/repair_input_origin_roster.dag | 13 +++++++-- src/v2/std/namespace_alias.dag | 28 ++++++++++++++++++- ...cross_module_reference_resolution_test.dag | 4 +-- .../repair_input_origin_denominator_test.dag | 8 ++++-- .../resolve/namespace_candidate_rule_test.dag | 23 ++++++++------- src/v2/workflow/floor_pure_producer_share.dag | 2 +- 7 files changed, 71 insertions(+), 27 deletions(-) diff --git a/src/v2/compiler/declaring_identity_spelling_census.dag b/src/v2/compiler/declaring_identity_spelling_census.dag index ca6cd7c2ecd..9d1a09b6b4f 100644 --- a/src/v2/compiler/declaring_identity_spelling_census.dag +++ b/src/v2/compiler/declaring_identity_spelling_census.dag @@ -20,7 +20,7 @@ import v2.std.diagnostic { Outcome, bind_outcome, outcome_accepted } import v2.std.language_model { LanguageModel } import v2.std.node { Node } import v2.std.symbol_index { empty_symbol_index } -import v2.compiler.normalized_tree { normalized_tree_roots_to_nodes } +import v2.compiler.normalized_tree { normalized_tree_roots_to_binding_sources } import v2.compiler.program_assembly { module_roots_from_source_root_ingest } import v2.compiler.reference_site_collector { UnreadableModuleRoot, @@ -38,7 +38,8 @@ import v2.compiler.resolution_provenance { resolve_reference_sites } import v2.compiler.source_authority { SourceRootIngest } -import v2.compiler.symbol_index_fill { symbol_index_fill_module_root_nodes } +import v2.compiler.symbol_index_fill { symbol_index_fill_module_roots } +import v2.std.namespace_alias { ModuleBindingSource, module_binding_source_roots } // XL-3 PRODUCER: one mention, one spelling row, over the production ingest route. // @@ -110,12 +111,21 @@ fn spelling_from_answer(answer: ResolutionProvenance) -> DeclaringIdentitySpelli } } -fn spelling_census_over_roots(roots: FreeMonoid) -> DeclaringIdentitySpellingCensus { +// THE INSTRUMENT'S INDEX IS BUILT THE WAY PRODUCTION BUILDS ITS OWN, and that is the whole reason +// this takes ModuleBindingSource rather than Node (review 69997 on gunbc#12009). While the +// corpus-wide spelling search was a resolution tier, an index missing the transmuted import +// bindings still answered the same for a cross-module bare reference, so the node-only door was +// harmless here. That package deletes the tier and replaces it with exactly those bindings, so a +// census built without them reports "unbound" for every cross-module reference PRODUCTION BINDS -- +// an instrument disagreeing with the route it exists to measure, in the denominator the namespace +// cut is defined by. +fn spelling_census_over_roots(sources: FreeMonoid) -> DeclaringIdentitySpellingCensus { + let roots = module_binding_source_roots(sources: sources) let collected = collect_reference_sites(roots: roots) DeclaringIdentitySpellingCensus { rows: fold_list( xs: resolve_reference_sites( - index: symbol_index_fill_module_root_nodes(index: empty_symbol_index(), roots: roots), + index: symbol_index_fill_module_roots(index: empty_symbol_index(), roots: sources), roster: module_roster_from_roots(roots: roots), sites: collected.sites ), @@ -137,7 +147,7 @@ fn spelling_census_from_ingest( f: fn(normalized) { outcome_accepted( value: spelling_census_over_roots( - roots: normalized_tree_roots_to_nodes(roots: normalized) + sources: normalized_tree_roots_to_binding_sources(roots: normalized) ) ) } diff --git a/src/v2/compiler/repair_input_origin_roster.dag b/src/v2/compiler/repair_input_origin_roster.dag index 4f4337635bb..926e716c53d 100644 --- a/src/v2/compiler/repair_input_origin_roster.dag +++ b/src/v2/compiler/repair_input_origin_roster.dag @@ -41,7 +41,8 @@ import v2.compiler.resolution_provenance { module_roster_from_roots, resolve_reference_sites } -import v2.compiler.symbol_index_fill { symbol_index_fill_module_root_nodes } +import v2.compiler.symbol_index_fill { symbol_index_fill_module_roots } +import v2.std.namespace_alias { ModuleBindingSource, module_binding_source_roots } // NAMESPACE-XL STAGE XL-0: THE REPAIR-INPUT ORIGIN DENOMINATOR, ON THE v2 ROUTE. // @@ -180,13 +181,19 @@ fn repair_input_origin_roster_is_total(roster: RepairInputOriginRoster) -> Bool // THE WHOLE DENOMINATOR OVER ONE BASE'S MODULE ROOTS: index, roster and sites all derived from the // same roots, so no consumer can pair a roster with an index built from a different tree. +// THE DENOMINATOR IS MEASURED AGAINST THE INDEX PRODUCTION RESOLVES WITH (review 69997 on +// gunbc#12009). This roster's digest is what gunbc.namespace_cut_stage consumes as +// DenominatorRosterDigest, so an index missing the transmuted import bindings would report every +// cross-module reference production BINDS as an unbound repair input -- inflating the very +// denominator the cut is defined by, with no red anywhere to say so. fn repair_input_origin_roster_over_roots( - roots: FreeMonoid + sources: FreeMonoid ) -> RepairInputOriginRoster { + let roots = module_binding_source_roots(sources: sources) let collected = collect_reference_sites(roots: roots) RepairInputOriginRoster { carriers: repair_input_origin_roster_from_sites( - index: symbol_index_fill_module_root_nodes(index: empty_symbol_index(), roots: roots), + index: symbol_index_fill_module_roots(index: empty_symbol_index(), roots: sources), module_roster: module_roster_from_roots(roots: roots), sites: collected.sites ), diff --git a/src/v2/std/namespace_alias.dag b/src/v2/std/namespace_alias.dag index 74d59f68b30..158fea8ba89 100644 --- a/src/v2/std/namespace_alias.dag +++ b/src/v2/std/namespace_alias.dag @@ -1,6 +1,7 @@ module v2.std.namespace_alias -import std.algebra { FreeMonoid } +import std.algebra { Empty, FreeMonoid } +import v2.std.algebra { fold_list, list_snoc_item } import v2.std.node { Node, Symbol } import v2.std.qualified_name { QualifiedName } @@ -36,3 +37,28 @@ type PendingBinding { module_qn: QualifiedName row: AliasBindingRow } + +// THE TWO PROJECTIONS A CONSUMER OF ModuleBindingSource NEEDS, so a caller never has to choose +// between "the roots" and "the bindings" and risk pairing them by position. +fn module_binding_source_roots(sources: FreeMonoid) -> FreeMonoid { + fold_list( + xs: sources, + empty: Empty, + cons: fn(acc, src) { list_snoc_item(xs: acc, item: src.root) } + ) +} + +// A ROOT WITH NOTHING IMPORTED, for a caller that genuinely has none -- a hand-built Node, not a +// parsed module. The name says that rather than letting `Empty` at a call site read as a caller +// who forgot, which is the same reason v2.compiler.normalized_tree spells no_import_bindings(). +fn module_binding_source_without_imports(root: Node) -> ModuleBindingSource { + ModuleBindingSource { root: root, import_bindings: Empty } +} + +fn module_binding_sources_without_imports(roots: FreeMonoid) -> FreeMonoid { + fold_list( + xs: roots, + empty: Empty, + cons: fn(acc, root) { list_snoc_item(xs: acc, item: module_binding_source_without_imports(root: root)) } + ) +} 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 80cbe30af3b..06ceeac1af7 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 @@ -8,7 +8,7 @@ import v2.compiler.name_resolve { } import v2.std.cross_tree.resolution { source_root_index_empty } import v2.std.resolution_policy { default_name_resolution_policy } -import v2.compiler.normalized_tree { NormalizedTree, normalized_tree_roots_to_nodes } +import v2.compiler.normalized_tree { NormalizedTree, normalized_tree_roots_to_nodes, normalized_tree_roots_to_binding_sources} import v2.compiler.program_assembly { module_roots_from_source_root_ingest } import v2.compiler.repair_input_origin_roster { RepairInputOriginRoster, @@ -434,7 +434,7 @@ test fn a_binder_shadowing_a_root_segment_refuses_ambiguous_holds() -> Bool { fn xl0r_roster() -> RepairInputOriginRoster { match xl0r_fixture_normalized() { Accepted { value: roots, diagnostics: _ } => - repair_input_origin_roster_over_roots(roots: normalized_tree_roots_to_nodes(roots: roots)) + repair_input_origin_roster_over_roots(sources: normalized_tree_roots_to_binding_sources(roots: roots)) Rejected { diagnostics: _ } => RepairInputOriginRoster { carriers: Empty, unreadable_roots: Empty } } diff --git a/src/v2/test/claim/namespace_xl0/repair_input_origin_denominator_test.dag b/src/v2/test/claim/namespace_xl0/repair_input_origin_denominator_test.dag index 36e53d904eb..a215e840bee 100644 --- a/src/v2/test/claim/namespace_xl0/repair_input_origin_denominator_test.dag +++ b/src/v2/test/claim/namespace_xl0/repair_input_origin_denominator_test.dag @@ -166,7 +166,7 @@ test fn a_mention_sits_at_its_containment_position_holds() -> Bool { sits_at_its_declaration && none_stamped_with_the_module } -data xl0_roster: FreeMonoid = repair_input_origin_roster_over_roots(roots: xl0_roots).carriers +data xl0_roster: FreeMonoid = repair_input_origin_roster_over_roots(sources: module_binding_sources_without_imports(roots: xl0_roots)).carriers fn xl0_roster_has(roster: FreeMonoid, f: fn(SourceDeclarationCarrierIdentity) -> Bool) -> Bool { fold_list( @@ -253,12 +253,14 @@ test fn an_unreadable_module_root_is_counted_and_not_silently_dropped_holds() -> // THE REFUSAL SURVIVES THE SEAM INTO THE ROSTER. A collector that counted the loss and a producer // that dropped it at the next call would be the same silent degradation one layer down. test fn the_roster_carries_the_unreadable_roots_it_was_built_over_holds() -> Bool { - let roster = repair_input_origin_roster_over_roots(roots: xl0_roots_with_one_unreadable) + let roster = repair_input_origin_roster_over_roots(sources: module_binding_sources_without_imports(roots: xl0_roots_with_one_unreadable)) length(xs: roster.unreadable_roots) == 1 && !repair_input_origin_roster_is_total(roster: roster) && repair_input_origin_roster_is_total( roster: repair_input_origin_roster_over_roots( - roots: Cons { head: xl0_consumer_root, tail: Empty } + sources: module_binding_sources_without_imports( + roots: Cons { head: xl0_consumer_root, tail: Empty } + ) ) ) } diff --git a/src/v2/test/claim/resolve/namespace_candidate_rule_test.dag b/src/v2/test/claim/resolve/namespace_candidate_rule_test.dag index 3b709369538..4672b1fb3f8 100644 --- a/src/v2/test/claim/resolve/namespace_candidate_rule_test.dag +++ b/src/v2/test/claim/resolve/namespace_candidate_rule_test.dag @@ -487,7 +487,6 @@ fn ncr_outcomes() -> NcrOutcomes { } } -data ncr_outcomes_warm: NcrOutcomes = ncr_outcomes() fn ncr_verdict_is_resolved(v: NcrVerdict) -> Bool { match v { @@ -555,7 +554,7 @@ fn ncr_verdict_same(left: NcrVerdict, right: NcrVerdict) -> Bool { // corpus-wide spelling is ambiguous and always was -- what makes the reference decidable is the // binding at this module's own position, which is the rule's whole claim. test fn a_bound_sibling_declaration_resolves_by_containment_holds() -> Bool { - match ncr_outcomes_warm { + match ncr_outcomes() { NcrContextRefused => false NcrOutcomesDecided { importer: importer, double: _, unbound: _, home_a: home_a, disp_user: _, shadow: _, chain: _, qual: _, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _, chain_permuted: _, qual_permuted: _, missing_import: _, singly_declared_leaf_is_unique: _, doubly_declared_leaf_is_ambiguous: _ } => ncr_verdict_is_resolved(v: importer) && ncr_verdict_is_resolved(v: home_a) @@ -566,7 +565,7 @@ test fn a_bound_sibling_declaration_resolves_by_containment_holds() -> Bool { // position, so the reference has two candidates and the rule may not pick. Either binder alone // would be a pick wearing a refusal's clothes, so the count is asserted as well as the membership. test fn a_genuine_ambiguity_refuses_naming_both_binders_holds() -> Bool { - match ncr_outcomes_warm { + match ncr_outcomes() { NcrContextRefused => false NcrOutcomesDecided { importer: _, double: double, unbound: _, home_a: _, disp_user: _, shadow: _, chain: _, qual: _, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _, chain_permuted: _, qual_permuted: _, missing_import: _, singly_declared_leaf_is_unique: _, doubly_declared_leaf_is_ambiguous: _ } => ncr_verdict_refuses_with(v: double, reason: ^resolve_reason_ambiguous_symbol) @@ -582,7 +581,7 @@ test fn a_genuine_ambiguity_refuses_naming_both_binders_holds() -> Bool { // to resolved if the search picks, to ambiguous if it refuses over the spelling -- and nothing else // in this file moves with it. test fn an_unbound_name_the_corpus_spells_twice_stays_unbound_holds() -> Bool { - match ncr_outcomes_warm { + match ncr_outcomes() { NcrContextRefused => false NcrOutcomesDecided { importer: _, double: _, unbound: unbound, home_a: _, disp_user: _, shadow: _, chain: _, qual: _, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _, chain_permuted: _, qual_permuted: _, missing_import: _, singly_declared_leaf_is_unique: _, doubly_declared_leaf_is_ambiguous: _ } => ncr_verdict_refuses_with(v: unbound, reason: ^resolve_reason_unbound_symbol) @@ -595,7 +594,7 @@ test fn an_unbound_name_the_corpus_spells_twice_stays_unbound_holds() -> Bool { // grain that would move -- the cause, and for the ambiguity the binder set -- rather than by a // pass/fail flag that two different refusals would share. test fn reordering_the_files_changes_no_verdict_holds() -> Bool { - match ncr_outcomes_warm { + match ncr_outcomes() { NcrContextRefused => false NcrOutcomesDecided { importer: importer, @@ -635,7 +634,7 @@ test fn reordering_the_files_changes_no_verdict_holds() -> Bool { // resolves. v2.ncr_disp_other stays in the ingest precisely so the spelling stays ambiguous -- drop // it and this arm would pass under either mechanism and discriminate nothing. test fn the_workload_bare_variant_reference_resolves_through_its_import_holds() -> Bool { - match ncr_outcomes_warm { + match ncr_outcomes() { NcrContextRefused => false NcrOutcomesDecided { importer: _, double: _, unbound: _, home_a: _, disp_user: disp_user, shadow: _, chain: _, qual: _, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _, chain_permuted: _, qual_permuted: _, missing_import: _, singly_declared_leaf_is_unique: _, doubly_declared_leaf_is_ambiguous: _ } => ncr_verdict_is_resolved(v: disp_user) @@ -647,7 +646,7 @@ test fn the_workload_bare_variant_reference_resolves_through_its_import_holds() // import if it overwrote, the declaration if the import were merely dropped -- so both are // asserted, and the count with them. test fn declaring_and_importing_one_name_refuses_naming_both_holds() -> Bool { - match ncr_outcomes_warm { + match ncr_outcomes() { NcrContextRefused => false NcrOutcomesDecided { importer: _, double: _, unbound: _, home_a: _, disp_user: _, shadow: shadow, chain: _, qual: _, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _, chain_permuted: _, qual_permuted: _, missing_import: _, singly_declared_leaf_is_unique: _, doubly_declared_leaf_is_ambiguous: _ } => ncr_verdict_refuses_with(v: shadow, reason: ^resolve_reason_ambiguous_symbol) @@ -664,7 +663,7 @@ test fn declaring_and_importing_one_name_refuses_naming_both_holds() -> Bool { // chain resolves AT ALL, which "unchanged under permutation" would not (two matching unbound // refusals are also unchanged). test fn a_re_export_chain_resolves_through_its_relay_holds() -> Bool { - match ncr_outcomes_warm { + match ncr_outcomes() { NcrContextRefused => false NcrOutcomesDecided { importer: _, double: _, unbound: _, home_a: _, disp_user: _, shadow: _, chain: chain, qual: _, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _, chain_permuted: _, qual_permuted: _, missing_import: _, singly_declared_leaf_is_unique: _, doubly_declared_leaf_is_ambiguous: _ } => ncr_verdict_is_resolved(v: chain) @@ -676,7 +675,7 @@ test fn a_re_export_chain_resolves_through_its_relay_holds() -> Bool { // that they now agree. The count is asserted because resolving to either claimant is what this // closes -- and that is how it failed: silently, with no diagnostic at all. test fn a_qualified_reference_to_a_contested_position_refuses_naming_both_holds() -> Bool { - match ncr_outcomes_warm { + match ncr_outcomes() { NcrContextRefused => false NcrOutcomesDecided { importer: _, double: _, unbound: _, home_a: _, disp_user: _, shadow: _, chain: _, qual: qual, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _, chain_permuted: _, qual_permuted: _, missing_import: _, singly_declared_leaf_is_unique: _, doubly_declared_leaf_is_ambiguous: _ } => ncr_verdict_refuses_with(v: qual, reason: ^resolve_reason_ambiguous_symbol) @@ -704,7 +703,7 @@ test fn a_qualified_reference_to_a_contested_position_refuses_naming_both_holds( // bare atom in a match arm names a constructor, and reads Ambiguous as yes. Every leaf polluted // this way would push a fresh arm BINDER toward being classified as a constructor and refused. test fn a_transmuted_import_does_not_make_its_leaf_globally_ambiguous_holds() -> Bool { - match ncr_outcomes_warm { + match ncr_outcomes() { NcrContextRefused => false NcrOutcomesDecided { importer: _, double: _, unbound: _, home_a: _, disp_user: _, shadow: _, chain: _, qual: _, @@ -748,7 +747,7 @@ fn ncr_verdict_declared_at(v: NcrVerdict, dotted: String) -> Bool { // declaring path. The fatal stays resolve_reason_unbound_symbol -- the advisory explains the // absence, it does not change what the refusal IS. test fn a_missing_binding_names_where_the_name_is_declared_holds() -> Bool { - match ncr_outcomes_warm { + match ncr_outcomes() { NcrContextRefused => false NcrOutcomesDecided { importer: _, double: _, unbound: _, home_a: _, disp_user: _, shadow: _, chain: _, qual: _, missing_import: missing_import, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _, chain_permuted: _, qual_permuted: _, singly_declared_leaf_is_unique: _, doubly_declared_leaf_is_ambiguous: _ } => ncr_verdict_refuses_with(v: missing_import, reason: ^resolve_reason_unbound_symbol) @@ -763,7 +762,7 @@ test fn a_missing_binding_names_where_the_name_is_declared_holds() -> Bool { // pointed at whichever declaration it found first -- which is the spelling search this package // deletes, readmitted as a diagnostic. test fn a_missing_binding_the_corpus_declares_twice_names_no_single_path_holds() -> Bool { - match ncr_outcomes_warm { + match ncr_outcomes() { NcrContextRefused => false NcrOutcomesDecided { importer: _, double: _, unbound: unbound, home_a: _, disp_user: _, shadow: _, chain: _, qual: _, missing_import: _, importer_permuted: _, double_permuted: _, unbound_permuted: _, disp_user_permuted: _, shadow_permuted: _, chain_permuted: _, qual_permuted: _, singly_declared_leaf_is_unique: _, doubly_declared_leaf_is_ambiguous: _ } => ncr_verdict_refuses_with(v: unbound, reason: ^resolve_reason_unbound_symbol) diff --git a/src/v2/workflow/floor_pure_producer_share.dag b/src/v2/workflow/floor_pure_producer_share.dag index a7f760166f5..dacbb72ea01 100644 --- a/src/v2/workflow/floor_pure_producer_share.dag +++ b/src/v2/workflow/floor_pure_producer_share.dag @@ -585,7 +585,7 @@ import v2.std.collection { List } // because the fill is two front ends over twelve modules, many times one claim's budget, and the // alternative is paying it nine times. data floor_cross_claim_pure_producers_warm: List = [ - "v2.test.claim.resolve.namespace_candidate_rule.ncr_outcomes_warm", + "v2.test.claim.resolve.namespace_candidate_rule.ncr_outcomes", "v2.test.cli.v2_native_cli.cli_probe_trailing_outcome", "v2.test.claim.native_route.native_refusal_detail.ndp_resolutions", "v2.test.claim.native_census.whole_tree_census_resolve.census_probe_outcomes", From cacd2ff5e8711d8e9f98ba5e5c1ada5ca95045f1 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Tue, 22 Sep 2026 06:45:13 +0000 Subject: [PATCH 15/17] A re-export's declaring path is the home's, not the relay's review 70003, finding 1, and it is the fork this PR exists to close reappearing one layer down. symbol_index_bind_pending_round handed a row's TARGET to symbol_index_bind_at as the declaring path. A target names a position the index can answer at, which is a declaration only when a declaration was written there; for a re-export it is another row's binding. So `Absent` reached through v2.std.collection recorded v2.std.collection.Absent while a direct reference recorded v2.std.optional.Absent - one declaration, one identity per relay - and translate would spell the second as a Rust path into a module that declares nothing. The chase needs no new authority: symbol_index_claimants_at already answers who declares what lives at a position, returning the position itself where pass A wrote a declaration and the recorded declaring path where a row bound. A relay's own row was chased the same way when it bound, so one step reaches the home rather than the next link. This was UNOBSERVABLE before this PR: while a resolved reference was a bare leaf, the recorded declaring path reached no consumer. The enrolled chain claim cannot see it either - it asserts that the chain resolves, and an index that stops at the relay resolves. Evidence, executed: a_re_export_resolves_to_the_home_not_the_relay_holds, whose NEGATIVE conjunct discriminates - FAIL before the chase, PASS after, with the sibling same-leaf row PASS in both runs. Full file 8/8 PASS. Also in this commit: - finding 3: the stale `length` import in v2.std.qualified_name, collateral from the previous commit's guard deletion. - finding 2: NOT the prescribed retype, and the derivation is recorded on DeclarationReferenceForm. A sibling's Symbol is a TOKEN CLASS spelled late by fixed_token_spelling_from_model; these three spell early into one Atom identity lexeme. Retyping them to Symbol would make them look like token classes while staying pre-spelled lexemes, which is the nickname rather than the repair. The repair is the reviewer's own sharper sentence - emit through token rows - and its trigger is a multi-token declaration-reference emission, blocked by the projection being a portable bundle that deliberately carries no TargetModel. - the class filed as gunbc.recurring_failure_mode a_binding_position_recorded_as_a_declaring_identity (DESIGN 4b). Co-Authored-By: Claude Opus 5 (1M context) --- ...ition_recorded_as_a_declaring_identity.dag | 23 +++++++++++++ src/v2/compiler/symbol_index_fill.dag | 3 +- src/v2/std/compilers/target_model.dag | 18 ++++++++++ src/v2/std/qualified_name.dag | 2 +- src/v2/std/symbol_index.dag | 22 ++++++++++++ ...cross_module_reference_resolution_test.dag | 34 ++++++++++++++++++- 6 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 dag/gunbc/recurring_failure_mode/a_binding_position_recorded_as_a_declaring_identity.dag diff --git a/dag/gunbc/recurring_failure_mode/a_binding_position_recorded_as_a_declaring_identity.dag b/dag/gunbc/recurring_failure_mode/a_binding_position_recorded_as_a_declaring_identity.dag new file mode 100644 index 00000000000..3dd6c973b74 --- /dev/null +++ b/dag/gunbc/recurring_failure_mode/a_binding_position_recorded_as_a_declaring_identity.dag @@ -0,0 +1,23 @@ +module gunbc.recurring_failure_mode.a_binding_position_recorded_as_a_declaring_identity + +import std.types { NonEmptyStr } +import std.decl_ref { DeclarationRef, WholeDeclaration } +import gunbc.recurring_failure_mode { RecurringFailureMode } + +data a_binding_position_recorded_as_a_declaring_identity: RecurringFailureMode = RecurringFailureMode { + identity: "a_binding_position_recorded_as_a_declaring_identity" as NonEmptyStr, + receipts: [ + "**a position that merely BINDS a name is recorded as the position that DECLARES it** (INVALID STATE: an index that keys declarations by their declaring path takes a row's TARGET as that path. A target names a position the index can answer at, which is a declaration only when a declaration was written there; for a re-export it is another row's binding. v2.compiler.symbol_index_fill symbol_index_bind_pending_round passed p.row.target straight to symbol_index_bind_at as declaring_path.)", + "HARM. One declaration acquires one identity PER RELAY it is reached through, so the map that exists to make identity single-valued is the thing that forks it. Nothing refuses: every path resolves, and the reference is Accepted. It surfaces downstream as a wrong answer with no diagnostic -- a target that spells a declaration by its module emits a path into the relay module, which declares nothing and therefore has no item to name, so the emitted program refers to something that was never written.", + "DISTINGUISHING FACTS. refusal_reason_minted_as_canonical_identity is a value minted from the WRONG VOCABULARY (a diagnostic reason standing in a name's place); this row is a value from the RIGHT vocabulary read at the WRONG LAYER -- a real qualified path, naming a real position, that is a binding rather than a declaration. The recognition rule is a question, not a spelling: when a field is named for a DECLARATION, ask whether every writer of it can only have written a declaration there, and follow one re-export before answering. An index whose lookup succeeds at a relay will answer that question wrong and stay green.", + "RECEIPT (2026-09-22, fierce-wren-487, found by review 70003 on gunbc#12048). Uninhabited until resolution began CARRYING the declaring path: while a resolved reference was a bare leaf the recorded declaring path reached no consumer, so the fork existed in the index and was unobservable. The enrolled chain claim could not see it either -- it asserted only that the chain RESOLVES, and an index that stops at the relay resolves perfectly well. Repaired by chasing one step through symbol_index_claimants_at, which already answers who declares what lives at a position; a relay's own row was chased the same way when it bound, so one step reaches the home. Enrolled: v2.test.claim.namespace_xl0.cross_module_reference_resolution a_re_export_resolves_to_the_home_not_the_relay_holds, whose NEGATIVE conjunct is the discriminator -- measured FAIL before the chase and PASS after, with the sibling same-leaf row PASS in both runs.", + "RUNG FOUND AT: outside the ladder -- silent wrongness.", + "CEILING: structurally impossible. A binding position and a declaring position are different concepts sharing one carrier (QualifiedName), so either can be written where the other is owed. The wall is the type: a declaring identity a binding path cannot inhabit, which is the same wall refusal_reason_minted_as_canonical_identity names from its own direction.", + "NEXT-RUNG TRIGGER: a declaration-identity carrier distinct from the qualified path of an arbitrary position, so that symbol_index_bind_at cannot be handed a binding position at all; until then the enrolled row above is the wall, and it is a permanent regression control rather than an expecting-red probe.", + ], + evidence: [ + DeclarationRef { module_path: "v2.compiler.symbol_index_fill", decl_name: "symbol_index_bind_pending_round", field: WholeDeclaration }, + DeclarationRef { module_path: "v2.std.symbol_index", decl_name: "symbol_index_declaring_path_of", field: WholeDeclaration }, + DeclarationRef { module_path: "v2.test.claim.namespace_xl0.cross_module_reference_resolution", decl_name: "a_re_export_resolves_to_the_home_not_the_relay_holds", field: WholeDeclaration }, + ], +} diff --git a/src/v2/compiler/symbol_index_fill.dag b/src/v2/compiler/symbol_index_fill.dag index 4ee8e36dc6f..1fdbb3b293b 100644 --- a/src/v2/compiler/symbol_index_fill.dag +++ b/src/v2/compiler/symbol_index_fill.dag @@ -33,6 +33,7 @@ import v2.std.qualified_name { QualifiedName, qualified_name_snoc } import v2.std.symbol_index { SymbolIndex, symbol_index_bind_at, + symbol_index_declaring_path_of, symbol_index_insert, symbol_index_lookup } @@ -263,7 +264,7 @@ fn symbol_index_bind_pending_round( index: symbol_index_bind_at( index: acc.index, binding_path: qualified_name_snoc(qn: p.module_qn, segment: p.row.binding), - declaring_path: p.row.target, + declaring_path: symbol_index_declaring_path_of(index: acc.index, target: p.row.target), resolved: resolved ), deferred: acc.deferred diff --git a/src/v2/std/compilers/target_model.dag b/src/v2/std/compilers/target_model.dag index 236bbebb4ee..f25bca1d263 100644 --- a/src/v2/std/compilers/target_model.dag +++ b/src/v2/std/compilers/target_model.dag @@ -9372,6 +9372,24 @@ type TargetTypeExpressionProjection { // target's own rows in extdeps/languages, and `NoDeclarationReferenceForm` is the honest row for a // target that has not modeled scoping -- a fixture target may declare it deliberately, which is // what makes the refusal's red authorable (DESIGN section 4b). +// +// WHY THESE THREE ARE String WHERE EVERY SIBLING ROW CARRIES Symbol, AND WHAT WOULD RETIRE THAT +// (review 70003 on gunbc#12048, which read the difference as a fork and is right that it is one). +// A sibling's `open`/`separator` is a TOKEN CLASS, emitted as FixedToken and spelled LATE by +// fixed_token_spelling_from_model against the target's lex rules. This row spells EARLY: the path +// collapses into one Atom identity lexeme, so what it needs is a LEXEME and not a class. Retyping +// these three to Symbol without moving the emission would make them LOOK like token classes while +// staying pre-spelled lexemes, which is the nickname DESIGN section 3 forbids rather than a repair +// of it. THE REPAIR IS THE EMISSION, NOT THE FIELD TYPE: a declaration reference emitted as a token +// SEQUENCE, so the path is readable back through the same rows it was emitted from (DESIGN section +// 4, one grammar read in both directions). What blocks it here is deliberate -- a +// TargetTypeExpressionProjection is a PORTABLE BUNDLE, encoded to a node and decoded on the other +// side (target_type_expr_declaration_reference_form_node, +// decode_declaration_reference_form_bundle) precisely so the type-expression fold never carries a +// whole TargetModel, and fixed_token_spelling_from_model needs one. TRIGGER: the capability is a +// multi-token declaration-reference emission in the type-expression layer -- an atom row takes one +// identity, so this needs a path form that emits a token sequence; when that exists these three +// become token classes and the concat here is deleted with it. type DeclarationReferenceForm = ModuleScopedPath { root_text: String diff --git a/src/v2/std/qualified_name.dag b/src/v2/std/qualified_name.dag index 52eba624ffe..dca611f5aa0 100644 --- a/src/v2/std/qualified_name.dag +++ b/src/v2/std/qualified_name.dag @@ -14,7 +14,7 @@ import v2.std.diagnostic { import std.algebra { Cons, Empty, FreeMonoid } import std.decl_ref { DeclarationRef, WholeDeclaration } import std.disposition { Disposition, Scaffold, RealizationDispatch } -import v2.std.algebra { HeadAbsent, HeadFound, fold_list_node, fold_list_right, is_empty, length, list_head, list_init, list_snoc_item } +import v2.std.algebra { HeadAbsent, HeadFound, fold_list_node, fold_list_right, is_empty, list_head, list_init, list_snoc_item } import v2.std.collection { List } diff --git a/src/v2/std/symbol_index.dag b/src/v2/std/symbol_index.dag index 140bda0eb51..25573cd7b56 100644 --- a/src/v2/std/symbol_index.dag +++ b/src/v2/std/symbol_index.dag @@ -246,6 +246,28 @@ fn symbol_index_claimants_at( } } +// A RE-EXPORT'S DECLARING PATH IS THE HOME'S, NOT THE RELAY'S. A row's target names a POSITION +// the index can answer at, and for a re-export chain that position is another row's BINDING -- +// `v2.compiler.normalize` imports `Absent` from `v2.std.collection`, which imports it from +// `v2.std.optional` and declares it nowhere. Handing that target back as a declaring path would +// give one declaration two identities, one per relay it was reached through, which is the fork this +// index exists to close (review 70003 on gunbc#12048). The chase is one step and needs no new +// authority: symbol_index_claimants_at ALREADY answers "who declares what lives at this position", +// returning the position itself when pass A wrote a declaration there and the recorded declaring +// path when a row bound there -- and a relay's own row was chased the same way when it bound, so +// one step reaches the terminal home rather than the next link. A caller reaching here through +// symbol_index_lookup has already been refused a contested position, so the claimant is unique; +// an unoccupied target is its own name, which is the pass-A reading. +fn symbol_index_declaring_path_of( + index: SymbolIndex, + target: QualifiedName +) -> QualifiedName { + match list_head(xs: symbol_index_claimants_at(index: index, binding_path: target)) { + HeadFound { value: declaring } => declaring + HeadAbsent => target + } +} + // THE GLOBAL-BARE ORACLE IS DELIBERATELY NOT WRITTEN HERE, and the reason is what the oracle // MEANS: how many DECLARATIONS spell this leaf. A binding is not a declaration -- it is a second // PATH to one that already exists -- so recording it there answers a different question with the 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 4d3c2091652..fb98a966b36 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 @@ -107,6 +107,10 @@ data xl0r_import_consumer_a_source: String = "module v2.test.xl0r_import_consume data xl0r_import_consumer_b_source: String = "module v2.test.xl0r_import_consumer_b\n\nimport v2.std.logic { Bool }\nimport v2.test.xl0r_provider_b { xl0r_same_leaf }\n\nfn xl0r_import_uses_b() -> Bool { xl0r_same_leaf }\n" +data xl0r_relay_source: String = "module v2.test.xl0r_relay\n\nimport v2.test.xl0r_provider_a { xl0r_same_leaf }\n" + +data xl0r_relay_consumer_source: String = "module v2.test.xl0r_relay_consumer\n\nimport v2.std.logic { Bool }\nimport v2.test.xl0r_relay { xl0r_same_leaf }\n\nfn xl0r_relay_uses() -> Bool { xl0r_same_leaf }\n" + data xl0r_field_access_consumer_source: String = "module v2.test.xl0r_field_access_consumer\n\nimport v2.std.logic { Bool }\n\nfn xl0r_projects(r: Bool) -> Bool { r.v }\n" data xl0r_method_call_consumer_source: String = "module v2.test.xl0r_method_call_consumer\n\nimport v2.std.logic { Bool }\n\nfn xl0r_calls_through(r: Bool, p: Bool) -> Bool { r.v(x: p) }\n" @@ -185,7 +189,13 @@ data xl0r_ingest: SourceRootIngest = Cons { head: xl0r_read(source: xl0r_import_consumer_a_source, id: ^xl0r_import_consumer_a_read, unit: ^xl0r_import_consumer_a_cu, path: "src/v2/test/fixture/namespace_xl0/resolution_import_consumer_a.dag"), tail: Cons { head: xl0r_read(source: xl0r_import_consumer_b_source, id: ^xl0r_import_consumer_b_read, unit: ^xl0r_import_consumer_b_cu, path: "src/v2/test/fixture/namespace_xl0/resolution_import_consumer_b.dag"), - tail: Empty + tail: Cons { + head: xl0r_read(source: xl0r_relay_source, id: ^xl0r_relay_read, unit: ^xl0r_relay_cu, path: "src/v2/test/fixture/namespace_xl0/resolution_relay.dag"), + tail: Cons { + head: xl0r_read(source: xl0r_relay_consumer_source, id: ^xl0r_relay_consumer_read, unit: ^xl0r_relay_consumer_cu, path: "src/v2/test/fixture/namespace_xl0/resolution_relay_consumer.dag"), + tail: Empty + } + } } } } @@ -291,6 +301,10 @@ fn xl0r_resolved_import_consumer_b() -> Outcome { xl0r_resolve_subject(subject: "v2.test.xl0r_import_consumer_b") } +fn xl0r_resolved_relay_consumer() -> Outcome { + xl0r_resolve_subject(subject: "v2.test.xl0r_relay_consumer") +} + // THE DECLARING PATHS A RESOLVED BODY CARRIES, read through the one reader the carrier has // (v2.std.qualified_name declaration_reference_path_optional): every qualified-name spine of two or // more segments in the resolved subtree. An Atom is not a reference to a corpus declaration after @@ -384,6 +398,24 @@ test fn two_same_leaf_targets_in_different_modules_resolve_to_distinct_declaring // no refusal anywhere -- one declaration's identity standing silently for another's. Against that // resolver this row is RED (xl0r_resolved_reference_paths answers Empty for an Atom); against the // carrier it holds: each body carries its own provider's path and not the other's. +// A RE-EXPORT RESOLVES TO THE HOME, NOT TO THE RELAY IT WAS REACHED THROUGH. `v2.test.xl0r_relay` +// imports `xl0r_same_leaf` and declares nothing, so its own binding position is what +// `v2.test.xl0r_relay_consumer` imports -- and the position a row TARGETS is not a declaration when +// that position is another row's binding. Handing the target back as the declaring path gives one +// declaration a second identity, one per relay it is reached through, which is the fork this +// carrier exists to close (review 70003): the same `xl0r_same_leaf` would answer +// `v2.test.xl0r_provider_a.xl0r_same_leaf` to a direct reference and +// `v2.test.xl0r_relay.xl0r_same_leaf` to this one, and translate would emit the second as a Rust +// path into a module the seed puts no item in, because a module that only imports declares nothing. +// THE NEGATIVE IS THE DISCRIMINATOR, not the positive: an index that stops at the relay still +// RESOLVES, so "the chain resolves at all" cannot see this. Red before +// v2.std.symbol_index symbol_index_declaring_path_of, green after. +test fn a_re_export_resolves_to_the_home_not_the_relay_holds() -> Bool { + let c = xl0r_resolved_relay_consumer() + xl0r_resolved_carries_reference(o: c, dotted: "v2.test.xl0r_provider_a.xl0r_same_leaf") + && !xl0r_resolved_carries_reference(o: c, dotted: "v2.test.xl0r_relay.xl0r_same_leaf") +} + test fn two_import_bound_same_leaf_targets_resolve_to_distinct_declaring_paths_holds() -> Bool { let a = xl0r_resolved_import_consumer_a() let b = xl0r_resolved_import_consumer_b() From ba627a16583039c3c68b4a9b9252c4cd1568110c Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Tue, 22 Sep 2026 06:46:52 +0000 Subject: [PATCH 16/17] Enrol the four xl0r resolve producers the roster was missing, including the new relay one Prompted by witty-cat-84's finding on gunbc#12009: a producer can be STORED and never SERVED, and the eleven claims still pay the full resolve. Mine are nullary fns and are served (CI measured the import row at 67,711 eval steps against the 72,300 budget), but four of the file's producers were never in the roster at all - xl0r_resolved_method_call_consumer, xl0r_resolved_import_consumer_a, xl0r_resolved_import_consumer_b, and the xl0r_resolved_relay_consumer this PR adds. 67,711 is 6% under the line, which is not headroom to add an unenrolled resolve beside. Enrolling them is what the file's other nine producers already do. Co-Authored-By: Claude Opus 5 (1M context) --- src/v2/workflow/floor_pure_producer_share.dag | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/v2/workflow/floor_pure_producer_share.dag b/src/v2/workflow/floor_pure_producer_share.dag index cdaf80683c4..1b68f446be8 100644 --- a/src/v2/workflow/floor_pure_producer_share.dag +++ b/src/v2/workflow/floor_pure_producer_share.dag @@ -617,6 +617,10 @@ data floor_cross_claim_pure_producers_warm: List = [ "v2.test.claim.namespace_xl0.cross_module_reference_resolution.xl0r_resolved_shadowing_consumer", "v2.test.claim.namespace_xl0.cross_module_reference_resolution.xl0r_resolved_consumer_a", "v2.test.claim.namespace_xl0.cross_module_reference_resolution.xl0r_resolved_consumer_b", + "v2.test.claim.namespace_xl0.cross_module_reference_resolution.xl0r_resolved_method_call_consumer", + "v2.test.claim.namespace_xl0.cross_module_reference_resolution.xl0r_resolved_import_consumer_a", + "v2.test.claim.namespace_xl0.cross_module_reference_resolution.xl0r_resolved_import_consumer_b", + "v2.test.claim.namespace_xl0.cross_module_reference_resolution.xl0r_resolved_relay_consumer", "v2.test.claim.namespace_xl0.qualified_site_enumerator_differential.qsd_normalized", "v2.test.claim.namespace_xl0.qualified_site_enumerator_differential.qsd_parsed", "v2.extdeps.languages.dag.dag_canonical_symbol_map", From e7e7abbbe1ab7f291b217d8cfa9b55b662edc9c4 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Tue, 22 Sep 2026 07:17:49 +0000 Subject: [PATCH 17/17] Delete a dangling leftover, and enrol the recompute my arity change exposed REVIEW 70024: ncr_verdicts_for had no call site -- a leftover of the pre-warm-producer shape that re-entered native_test_context_from_ingest per call, which is precisely what the warm row exists to avoid. Leaving it would have left a second route to the same fact for a later author to pick up. Deleted. THE FLOOR'S LAST BLOCKER, and it is not a regression. The warm-producer repair worked: the nine namespace_candidate_rule identities went from ~1,005,000 eval steps each to no blocking lines at all, with [floor-phase] pure-producer-share-warm producer=...namespace_candidate_rule.ncr_outcomes disposition=Stored cpu_ms=2044. Nine blockers to one. The one left is v2.test.manual.body_lowering_normalize_add.body_lowering_normalized_arrow_root_resolves at 115,051 steps against the 72,300 new-witness budget. It is not mine and its cost did not change. What changed is that it became VISIBLE: this PR alters the arity of admit_normalized_tree, that file calls it, so its identities read as changed and a withheld cost debt stopped being withheld. I CHECKED THE RELAYED EXPLANATION INSTEAD OF ACCEPTING IT. The standing reads withhold-overridden-for-changed-verdict and it was reported to me as the verdict having moved in my run. It did not: body_lowering_normalized_arrow_root_resolves returns true on origin/main and on this head, same interpreter, sources the only variable. So nothing about that claim's behaviour changed and there is nothing to revert. body_lowering_normalized_module() is a nullary pure producer called from NINETEEN sites in that file, never enrolled, each call paying one front end over a source literal. Its value is an Optional -- root Node, marker channel, binding rows, no resolution context and no closure -- portable for the same reason ndp_resolutions is. Enrolled warm, with the measurement and the it-was-always-this-expensive reasoning on the entry. That is the repair this roster exists for. Re-hiding the debt would have been the alternative, and a cost that is only invisible while nobody touches the file is not a cost that has been dealt with. 11/11 controls. Co-Authored-By: Claude Opus 5 (1M context) --- .../resolve/namespace_candidate_rule_test.dag | 12 ------------ src/v2/workflow/floor_pure_producer_share.dag | 14 ++++++++++++++ 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/v2/test/claim/resolve/namespace_candidate_rule_test.dag b/src/v2/test/claim/resolve/namespace_candidate_rule_test.dag index 4672b1fb3f8..87ad35f2eaa 100644 --- a/src/v2/test/claim/resolve/namespace_candidate_rule_test.dag +++ b/src/v2/test/claim/resolve/namespace_candidate_rule_test.dag @@ -423,18 +423,6 @@ type NcrOutcomes doubly_declared_leaf_is_ambiguous: Bool } -fn ncr_verdicts_for(ingest: SourceRootIngest, module_name: String) -> Optional { - match native_test_context_from_ingest(ingest: ingest) { - Rejected { diagnostics: _ } => optional_absent() - Accepted { value: context, diagnostics: _ } => - ncr_outcome_in( - context: context, - index: native_lane_file_refusal_index(refusals: context.file_refusals), - ingest: ingest, - module_name: module_name - ) - } -} fn ncr_outcomes() -> NcrOutcomes { match native_test_context_from_ingest(ingest: ncr_ingest) { diff --git a/src/v2/workflow/floor_pure_producer_share.dag b/src/v2/workflow/floor_pure_producer_share.dag index dacbb72ea01..d088a4c2150 100644 --- a/src/v2/workflow/floor_pure_producer_share.dag +++ b/src/v2/workflow/floor_pure_producer_share.dag @@ -584,7 +584,21 @@ import v2.std.collection { List } // no resolution context and no closure, portable for the same reason census_probe_outcomes is. WARM // because the fill is two front ends over twelve modules, many times one claim's budget, and the // alternative is paying it nine times. +// THE BODY-LOWERING NORMALIZE ROW IS THE SAME GROUND, FOUND BY BEING EXPOSED RATHER THAN BY BEING +// NEW. v2.test.manual.body_lowering_normalize_add calls body_lowering_normalized_module() -- one +// front end over a source literal -- from NINETEEN sites, and the value is an Optional: +// a root Node, a marker channel and a binding row list, no resolution context and no closure, +// portable for the same reason ndp_resolutions is. +// +// IT WAS ALWAYS THIS EXPENSIVE; what changed is that it became visible. gunbc#12009 altered the +// arity of admit_normalized_tree, which this file calls, so its identities were read as changed and +// a withheld cost debt stopped being withheld -- body_lowering_normalized_arrow_root_resolves at +// 115,051 steps against the 72,300 new-witness budget, the single remaining blocker on that head. +// The verdict did NOT move: it returns true on origin/main and on that head, same interpreter, +// sources the only variable. So this is not a regression to revert but a pre-existing recompute the +// change surfaced, and the honest repair is the one this roster exists for rather than re-hiding it. data floor_cross_claim_pure_producers_warm: List = [ + "v2.test.manual.body_lowering_normalize_add.body_lowering_normalized_module", "v2.test.claim.resolve.namespace_candidate_rule.ncr_outcomes", "v2.test.cli.v2_native_cli.cli_probe_trailing_outcome", "v2.test.claim.native_route.native_refusal_detail.ndp_resolutions",