iter raw-buf.3 typescoped-intrinsic-mechanism (DONE 2/2): scope-qualified mono symbols + bijection expansion (refs #7)

New language mechanism: a type-scoped polymorphic top-level (intrinsic)
op now mints a scope-qualified mono symbol (StubT_peek__Int, not the
colliding bare peek__Int), and the intrinsic<->intercept bijection
expresses one such polymorphic marker as its N per-param-in-element
entries. Ratified standalone on the stub via a new StubT.peek op; no
RawBuf, no Term::New, no codegen E2E this iteration (RawBuf consumes the
mechanism in raw-buf.4).

Task 1 — scope threading. Added scope: Option<String> to FreeFnCall +
MonoTarget::FreeFn + the synth free_fn_owner tuple. Populated Some(prefix)
ONLY on the type-scoped T.f resolution branch (lib.rs:3538, gated on
type_home.is_some()); None on every local / same-module / implicit-import
branch, so existing bare-resolved symbols stay byte-identical. Consumed
via a scoped_base helper at both mono-symbol mint sites (synthesise +
rewrite, which read the same MonoTarget → automatically in lockstep) and
folded into mono_target_key so a scoped op and a bare op of the same name
never dedup-collide.

Task 2 — ratifier + bijection. Added StubT.peek to the stub source;
extended workspace_intrinsic_markers with a type-scoped-poly arm that
finds the unique same-module TypeDef referenced in the fn signature and
expands over its param-in set, building the T_f__<elem> strings via the
SAME mono_symbol_n on Type::Con elems so collector and mint agree
byte-for-byte; registered StubT_peek__Int/Float entries + emit fns;
extended kernel_stub_module_round_trips to cover peek; added
mono_scoped_symbol integration test (own-param calls, no Term::New, no
codegen — asserts both scoped symbols minted and bare peek__Int absent).

Latent bug fixed inline (regression caught at the full-suite gate, RED via
the existing escape_local_demo E2E). kernel_stub is implicitly imported,
so poly_free_fn_names_for_module unconditionally added peek's bare name to
every consumer; a consumer with its OWN monomorphic peek then had its
local call mistreated as a poly-free-fn site, over-advancing the mono slot
cursor and misaligning a print target. Fix: suppress the bare
implicit-import name when the current module declares a same-name global,
mirroring synth's resolution ladder (same-module global beats
implicit-import). Applied in lockstep to both poly_free_fn name + and
constraint-count maps; dot-qualified / type-scoped spellings stay
unconditional. This was a pre-existing inconsistency between mono's
name-set and synth's resolution that peek (first implicitly-imported poly
free fn colliding with a fixture-local name) exposed.

Plan deviations (all sound): collect_con_heads omits Borrow/Own recursion
(no such Type variant — borrow/own are ParamMode on Type::Fn, verified
ast.rs:752); scope bound via the existing synthesise_mono_fn_for_free_fn
MonoTarget destructure rather than a new param; the mono-pin fixture uses
own params (borrow + peek-returns-a trips consume-while-borrowed; peek is
never instantiated so the mode is immaterial to the scope-symbol
ratification).

Verification (orchestrator, this session): cargo build --workspace clean;
cargo test --workspace 670 passed / 0 failed / 2 ignored (669 baseline +
the one new mono test). Bijection green (1 marker -> 2 entries),
round-trip green, escape_local_demo green. .ll snapshots unchanged (peek
polymorphic -> never instantiated without a call site). Existing eq__*/
compare__*/float_* symbols byte-identical (mono_hash_stability green).
This commit is contained in:
2026-05-29 19:39:35 +02:00
parent 8508182f84
commit fd37fa6489
8 changed files with 292 additions and 18 deletions
+49
View File
@@ -0,0 +1,49 @@
//! raw-buf.3: a type-scoped polymorphic intrinsic call `StubT.peek`
//! monomorphises to the scope-qualified symbol `StubT_peek__<elem>`
//! (not the bare `peek__<elem>`). Proves the scope threads
//! resolution → FreeFnCall → MonoTarget → mint. No codegen / no
//! Term::New: assertion is on the monomorphised def names.
use ailang_core::ast::Def;
use ailang_surface::load_workspace;
use std::path::PathBuf;
fn fixture_path() -> PathBuf {
let mut d = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
d.pop();
d.pop();
d.join("examples").join("peek_mono_pin_smoke.ail")
}
#[test]
fn stubt_peek_monomorphises_to_scope_qualified_symbols() {
let ws = load_workspace(&fixture_path()).expect("workspace loads");
let diags = ailang_check::check_workspace(&ws);
assert!(diags.is_empty(), "typecheck diagnostics: {diags:?}");
let post_mono = ailang_check::monomorphise_workspace(&ws).expect("mono green");
// Free-fn mono defs are appended to the polymorphic source's
// defining module — `kernel_stub`, where `peek` lives.
let names: Vec<String> = post_mono
.modules
.values()
.flat_map(|m| m.defs.iter())
.filter_map(|d| match d {
Def::Fn(f) => Some(f.name.clone()),
_ => None,
})
.collect();
assert!(
names.iter().any(|n| n == "StubT_peek__Int"),
"expected scope-qualified StubT_peek__Int; got: {names:?}"
);
assert!(
names.iter().any(|n| n == "StubT_peek__Float"),
"expected scope-qualified StubT_peek__Float; got: {names:?}"
);
assert!(
!names.iter().any(|n| n == "peek__Int"),
"bare peek__Int must NOT be minted (scope-qualified only)"
);
}
+2
View File
@@ -179,6 +179,7 @@ fn synthesise_mono_fn_free_fn_arm_substitutes_rigid_vars() {
name: "cmp_max".into(),
type_args: vec![int_ty.clone()],
defining_module: "cmp_max_smoke".into(),
scope: None,
};
let synth = synthesise_mono_fn_for_free_fn(&target, &ws)
.expect("synthesis succeeds");
@@ -212,6 +213,7 @@ fn mono_target_free_fn_variant_keys_distinctly_from_class_method() {
name: "cmp_max".into(),
type_args: vec![int_ty.clone()],
defining_module: "prelude".into(),
scope: None,
};
assert_ne!(
mono_target_key(&class_tgt),
+24 -6
View File
@@ -2753,6 +2753,13 @@ pub struct FreeFnCall {
/// yields the concrete type for `forall_vars[i]` iff the call site's
/// substitution was fully pinned.
pub metas: Vec<Type>,
/// The TypeDef scope the call resolved through, when it was
/// spelled type-scoped (`T.f`, prep.1) — `Some("StubT")` for
/// `StubT.peek`, `Some("RawBuf")` for `RawBuf.get`. `None` for a
/// bare / dot-qualified / local resolution. raw-buf.3: drives the
/// scope-qualified mono symbol `T_f__<elem>` so two types' ops
/// of the same name never collide.
pub scope: Option<String>,
}
/// lift a canonical-form class-ref value to the qualified
@@ -3307,7 +3314,7 @@ pub(crate) fn synth(
}
};
let (raw, free_fn_owner): (Type, Option<(String, String)>) = if let Some(t) = locals.get(name) {
let (raw, free_fn_owner): (Type, Option<(String, String, Option<String>)>) = if let Some(t) = locals.get(name) {
// Locals (lambda / let / fn-params) — fn wins per
// lookup precedence. A class method shadowed by a
// local binding fires the shadow warning so the
@@ -3323,7 +3330,7 @@ pub(crate) fn synth(
.module_globals
.get(&env.current_module)
.filter(|m| m.contains_key(name))
.map(|_| (env.current_module.clone(), name.clone()));
.map(|_| (env.current_module.clone(), name.clone(), None));
emit_shadow_warning_if_class_method(name, env.current_module.as_str(), warnings);
(t.clone(), owner)
} else if let Some((owner_module, raw_ty)) = env
@@ -3361,7 +3368,7 @@ pub(crate) fn synth(
let qualified = qualify_local_types(&raw_ty, &owner_module, &owner_types);
// Bare name resolves through implicit import; the
// unqualified name in the owner module is the same `name`.
(qualified, Some((owner_module, name.clone())))
(qualified, Some((owner_module, name.clone(), None)))
} else if is_class_method_dispatch(name, env) {
// type-driven dispatch entry. The Var arm runs
// before App-arm unification, so the arg type is not
@@ -3531,8 +3538,18 @@ pub(crate) fn synth(
qualify_local_types(&raw_ty, &target_module, &owner_types)
};
// Dot-qualified `prefix.suffix`: the unqualified name in
// the owner module is `suffix`.
(qualified, Some((target_module, suffix.to_string())))
// the owner module is `suffix`. raw-buf.3: when the
// resolution went through the TypeDef-first ladder
// (`type_home.is_some()`), the call is type-scoped — carry
// the TypeDef prefix as the mono-symbol scope. The legacy
// module-as-receiver sub-case (`type_home == None`) is not
// type-scoped → `scope = None`.
let scope = if type_home.is_some() {
Some(prefix.to_string())
} else {
None
};
(qualified, Some((target_module, suffix.to_string(), scope)))
} else {
return Err(CheckError::UnknownIdent(name.clone()));
};
@@ -3546,7 +3563,7 @@ pub(crate) fn synth(
// by bare name); the source-level `name` may be a
// dot-qualified form (`prefix.suffix`) the synth started
// with.
if let (Type::Forall { vars, constraints, body }, Some((owner, unqualified_name))) =
if let (Type::Forall { vars, constraints, body }, Some((owner, unqualified_name, scope))) =
(&raw, &free_fn_owner)
{
let (metas, inst) = instantiate(vars, body, counter);
@@ -3604,6 +3621,7 @@ pub(crate) fn synth(
owner_module: owner.clone(),
forall_vars: vars.clone(),
metas: metas.clone(),
scope: scope.clone(),
});
return Ok(inst);
}
+57 -10
View File
@@ -358,6 +358,22 @@ fn poly_free_fn_names_for_module(
// `<TypeName>.<fn>` for every TypeDef declared in the imported
// module — `synth`'s Var-arm resolves these via the TypeDef-first
// ladder and `rewrite_mono_calls` consumes the same spelling.
//
// raw-buf.3: the bare implicit-import name is suppressed when the
// current module declares a same-name global. `synth`'s Var-arm
// resolves a same-module global (lib.rs ~3317) BEFORE the
// implicit-import fall-through (~3329), so a bare call there is the
// local def, not the imported poly fn. Adding the bare name here
// anyway would make `rewrite_mono_calls` treat the local call as a
// poly-free-fn site and over-advance the slot cursor (surfaced when
// kernel_stub gained a `peek` colliding with a fixture-local
// monomorphic `peek`). The dot-qualified / type-scoped spellings
// are unambiguous and stay unconditional.
let local_global_names: BTreeSet<String> = env
.module_globals
.get(mname)
.map(|g| g.keys().cloned().collect())
.unwrap_or_default();
if let Some(imports) = env.module_imports.get(mname) {
for (alias_or_name, target_mod) in imports {
let target_types: Vec<String> = env
@@ -370,7 +386,10 @@ fn poly_free_fn_names_for_module(
if matches!(t, Type::Forall { .. }) {
// Implicit-import bare name (today only the
// prelude is implicit; the loop is generic).
out.insert(n.clone());
// Suppressed if shadowed by a same-module global.
if !local_global_names.contains(n) {
out.insert(n.clone());
}
// Dot-qualified form.
out.insert(format!("{alias_or_name}.{n}"));
// prep.1: type-scoped form per TypeDef.
@@ -419,6 +438,15 @@ fn poly_free_fn_constraint_counts_for_module(
// prep.1: also enumerate type-scoped spellings `<TypeName>.<fn>`
// per TypeDef in the imported module — mirror of
// `poly_free_fn_names_for_module`.
//
// raw-buf.3: same bare-name shadow suppression as
// `poly_free_fn_names_for_module` — kept in lockstep so the two
// maps agree on which spellings count as poly-free-fn sites.
let local_global_names: BTreeSet<String> = env
.module_globals
.get(mname)
.map(|g| g.keys().cloned().collect())
.unwrap_or_default();
if let Some(imports) = env.module_imports.get(mname) {
for (alias_or_name, target_mod) in imports {
let target_types: Vec<String> = env
@@ -429,7 +457,9 @@ fn poly_free_fn_constraint_counts_for_module(
if let Some(target_globals) = env.module_globals.get(target_mod) {
for (n, t) in target_globals {
if let Type::Forall { constraints, .. } = t {
out.insert(n.clone(), constraints.len());
if !local_global_names.contains(n) {
out.insert(n.clone(), constraints.len());
}
out.insert(format!("{alias_or_name}.{n}"), constraints.len());
for tn in &target_types {
out.insert(format!("{tn}.{n}"), constraints.len());
@@ -527,6 +557,17 @@ pub fn mono_symbol_n(base: &str, types: &[Type]) -> String {
parts.join("__")
}
/// raw-buf.3: the symbol base for a free-fn mono target. A
/// type-scoped op (`scope = Some("StubT")`, name `"peek"`) bases on
/// `"StubT_peek"`, so `mono_symbol_n` mints `StubT_peek__Int`; a
/// bare free fn (`scope = None`) keeps its bare base.
pub fn scoped_base(scope: &Option<String>, name: &str) -> String {
match scope {
Some(t) => format!("{t}_{name}"),
None => name.to_string(),
}
}
fn type_to_mono_suffix(ty: &Type) -> String {
match primitive_surface_name(ty) {
Some(p) => p.to_string(),
@@ -581,6 +622,10 @@ pub enum MonoTarget {
/// Module in which the polymorphic source `Def::Fn` is declared;
/// the synthesised mono `Def::Fn` is appended there.
defining_module: String,
/// The TypeDef scope (`Some("StubT")`) when the call resolved
/// type-scoped; `None` for a bare free fn. Part of the dedup
/// key and the minted symbol base.
scope: Option<String>,
},
}
@@ -597,13 +642,13 @@ pub fn mono_target_key(t: &MonoTarget) -> (String, String, String) {
format!("{class}.{method}"),
ailang_core::canonical::type_hash(type_),
),
MonoTarget::FreeFn { name, type_args, .. } => {
MonoTarget::FreeFn { name, type_args, scope, .. } => {
let joined = type_args
.iter()
.map(ailang_core::canonical::type_hash)
.collect::<Vec<_>>()
.join("|");
("free".into(), name.clone(), joined)
("free".into(), scoped_base(scope, name), joined)
}
}
}
@@ -858,6 +903,7 @@ pub fn collect_mono_targets(
name: fc.name.clone(),
type_args,
defining_module: fc.owner_module.clone(),
scope: fc.scope.clone(),
});
}
Ok(out)
@@ -1004,9 +1050,9 @@ pub fn synthesise_mono_fn_for_free_fn(
target: &MonoTarget,
ws: &Workspace,
) -> Result<AstFnDef> {
let (name, type_args, defining_module) = match target {
MonoTarget::FreeFn { name, type_args, defining_module } => {
(name, type_args, defining_module)
let (name, type_args, defining_module, scope) = match target {
MonoTarget::FreeFn { name, type_args, defining_module, scope } => {
(name, type_args, defining_module, scope)
}
MonoTarget::ClassMethod { .. } => {
return Err(crate::CheckError::Internal(
@@ -1077,7 +1123,7 @@ pub fn synthesise_mono_fn_for_free_fn(
let new_body = crate::substitute_rigids_in_term(&source_fn.body, &mapping);
Ok(AstFnDef {
name: mono_symbol_n(name, type_args.as_slice()),
name: mono_symbol_n(&scoped_base(scope, name), type_args.as_slice()),
ty: new_type,
params: source_fn.params.clone(),
body: new_body,
@@ -1172,8 +1218,8 @@ fn rewrite_mono_calls(
MonoTarget::ClassMethod { method, type_, defining_module, .. } => {
(mono_symbol(method, type_), defining_module.as_str())
}
MonoTarget::FreeFn { name: fn_name, type_args, defining_module } => {
(mono_symbol_n(fn_name, type_args.as_slice()), defining_module.as_str())
MonoTarget::FreeFn { name: fn_name, type_args, defining_module, scope } => {
(mono_symbol_n(&scoped_base(scope, fn_name), type_args.as_slice()), defining_module.as_str())
}
};
let new_name = if defining_module == caller_module {
@@ -1490,6 +1536,7 @@ pub(crate) fn collect_residuals_ordered(
name: fc.name.clone(),
type_args,
defining_module: fc.owner_module.clone(),
scope: fc.scope.clone(),
})
})
.collect();
+138 -2
View File
@@ -171,6 +171,20 @@ pub(crate) static INTERCEPTS: &[Intercept] = &[
wants_alwaysinline: false,
emit: emit_answer,
},
Intercept {
name: "StubT_peek__Int",
expected_params: &["ptr"], // borrow (con StubT Int) lowers to ptr
expected_ret: "i64",
wants_alwaysinline: false,
emit: emit_stubt_peek_int,
},
Intercept {
name: "StubT_peek__Float",
expected_params: &["ptr"],
expected_ret: "double",
wants_alwaysinline: false,
emit: emit_stubt_peek_float,
},
];
pub(crate) fn lookup(name: &str) -> Option<&'static Intercept> {
@@ -458,11 +472,38 @@ pub(crate) fn emit_answer(emitter: &mut Emitter<'_>) -> Result<()> {
Ok(())
}
/// raw-buf.3 ratifier emit. NOTE: never instantiated to codegen this
/// iteration (no Term::New to build a StubT; unit-test-ratified only).
/// The load offset/correctness is exercised end-to-end only by
/// raw-buf.4's RawBuf path; here the entry exists to satisfy the
/// bijection. Retires with the stub in raw-buf.5.
pub(crate) fn emit_stubt_peek_int(emitter: &mut Emitter<'_>) -> Result<()> {
let n = emitter.locals.len();
let s = emitter.locals[n - 1].1.clone();
let v = emitter.fresh_ssa();
emitter.body.push_str(&format!(" {v} = load i64, ptr {s}\n"));
emitter.body.push_str(&format!(" ret i64 {v}\n"));
emitter.body.push_str("}\n\n");
emitter.block_terminated = true;
Ok(())
}
pub(crate) fn emit_stubt_peek_float(emitter: &mut Emitter<'_>) -> Result<()> {
let n = emitter.locals.len();
let s = emitter.locals[n - 1].1.clone();
let v = emitter.fresh_ssa();
emitter.body.push_str(&format!(" {v} = load double, ptr {s}\n"));
emitter.body.push_str(&format!(" ret double {v}\n"));
emitter.body.push_str("}\n\n");
emitter.block_terminated = true;
Ok(())
}
#[cfg(test)]
mod tests {
use super::{lookup, INTERCEPTS};
use ailang_check::mono::mono_symbol;
use ailang_core::ast::{Def, Term};
use ailang_check::mono::{mono_symbol, mono_symbol_n};
use ailang_core::ast::{Def, FnDef, Module, Term, Type};
use std::collections::BTreeSet;
/// INTERCEPTS entries that intercept the monomorphised `__Int`
@@ -485,6 +526,34 @@ mod tests {
] {
for def in &module.defs {
match def {
// raw-buf.3: a polymorphic top-level (intrinsic) op
// type-scoped to a same-module TypeDef T (its
// signature mentions `(con T …)`) expands to one
// marker per element type in T's `param-in` set —
// the same `T_f__<elem>` strings the mono pass mints
// (mono::scoped_base + mono_symbol_n). One marker → N
// entries.
Def::Fn(f)
if matches!(f.body, Term::Intrinsic)
&& matches!(f.ty, Type::Forall { .. }) =>
{
match scope_typedef_and_elems(f, &module) {
Some((tdef, elems)) => {
for elem in elems {
markers.insert(mono_symbol_n(
&format!("{tdef}_{}", f.name),
std::slice::from_ref(&elem),
));
}
}
// a Forall intrinsic not scoped to a
// param-in TypeDef keeps the bare name (no
// such case ships today; future-proofing).
None => {
markers.insert(f.name.clone());
}
}
}
// Top-level intrinsic fn: name is already the symbol
// (float_eq, answer, ...).
Def::Fn(f) if matches!(f.body, Term::Intrinsic) => {
@@ -548,4 +617,71 @@ mod tests {
"optimisation-only allowlist names not in INTERCEPTS: {stale_allow:?}"
);
}
/// raw-buf.3: for a type-scoped polymorphic intrinsic fn `f`, return
/// `(TypeDef-name, element-types)` where the TypeDef is the unique
/// same-module `Def::Type` referenced via `(con T …)` in `f`'s
/// signature and the element types are its `param-in` set expressed as
/// `Type::Con` values (so `mono_symbol_n` produces the same suffix the
/// mono pass mints). `None` if `f` references zero or several
/// same-module TypeDefs.
fn scope_typedef_and_elems(f: &FnDef, module: &Module) -> Option<(String, Vec<Type>)> {
let typedef_names: BTreeSet<&str> = module
.defs
.iter()
.filter_map(|d| match d {
Def::Type(td) => Some(td.name.as_str()),
_ => None,
})
.collect();
// collect Con-heads referenced anywhere in f's type
let mut referenced: BTreeSet<String> = BTreeSet::new();
collect_con_heads(&f.ty, &mut referenced);
let scoped: Vec<&String> = referenced
.iter()
.filter(|n| typedef_names.contains(n.as_str()))
.collect();
let tdef = match scoped.as_slice() {
[one] => (*one).clone(),
_ => return None,
};
let td = module.defs.iter().find_map(|d| match d {
Def::Type(td) if td.name == tdef => Some(td),
_ => None,
})?;
// param-in is keyed by the type var; take its allowed surface
// names and build `Type::Con` element types. The element strings
// (`Int`, `Float`) flow through `mono_symbol_n`'s
// `primitive_surface_name` gate to the same `__Int` / `__Float`
// suffixes the mono pass mints.
let allowed = td.param_in.values().next()?; // single-var TypeDefs
let elems: Vec<Type> = allowed
.iter()
.map(|s| Type::Con { name: s.clone(), args: vec![] })
.collect();
Some((tdef, elems))
}
/// raw-buf.3: push every `Type::Con` head name reachable in `ty`
/// into `out`. `borrow` / `own` are `ParamMode` metadata on
/// `Type::Fn`, not wrapper `Type` variants, so the only recursive
/// shapes are `Forall.body`, `Fn.params`/`ret`, and `Con.args`.
fn collect_con_heads(ty: &Type, out: &mut BTreeSet<String>) {
match ty {
Type::Con { name, args } => {
out.insert(name.clone());
for a in args {
collect_con_heads(a, out);
}
}
Type::Fn { params, ret, .. } => {
for p in params {
collect_con_heads(p, out);
}
collect_con_heads(ret, out);
}
Type::Forall { body, .. } => collect_con_heads(body, out),
Type::Var { .. } => {}
}
}
}
@@ -763,4 +763,12 @@ fn kernel_stub_module_round_trips() {
let allowed = td.param_in.get("a").expect("StubT.a restricted");
assert!(allowed.contains("Int"));
assert!(allowed.contains("Float"));
// raw-buf.3: the type-scoped polymorphic intrinsic ratifier op
// round-trips and is an `(intrinsic)` marker.
let peek = recovered.defs.iter().find_map(|d| match d {
Def::Fn(f) if f.name == "peek" => Some(f),
_ => None,
}).expect("kernel_stub ships the peek ratifier op (raw-buf.3)");
assert!(matches!(peek.body, Term::Intrinsic), "peek is an (intrinsic) marker");
}
@@ -12,4 +12,9 @@
(doc "Ratifies the intrinsic mechanism end-to-end: codegen intercept answer emits ret i64 42.")
(type (fn-type (params) (ret (con Int))))
(params)
(intrinsic))
(fn peek
(doc "Ratifies the type-scoped polymorphic intrinsic mechanism: StubT.peek is polymorphic over a (param-in {Int,Float}) and type-scoped to StubT. Codegen intercepts StubT_peek__Int / StubT_peek__Float emit a load of the Stub payload. Retires with the stub in raw-buf.5.")
(type (forall (vars a) (fn-type (params (borrow (con StubT a))) (ret a))))
(params s)
(intrinsic)))
+9
View File
@@ -0,0 +1,9 @@
(module peek_mono_pin_smoke
(fn at_int
(type (fn-type (params (own (con StubT (con Int)))) (ret (con Int))))
(params s)
(body (app StubT.peek s)))
(fn at_float
(type (fn-type (params (own (con StubT (con Float)))) (ret (con Float))))
(params s)
(body (app StubT.peek s))))