Skip to content
Merged
9 changes: 7 additions & 2 deletions src/v1/complexity.dag
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,12 @@ type CostInternTable {
summaries: Map<String, ComplexitySummary>
}

fn empty_intern_table() -> CostInternTable {
// Distinct concept from v1.std.core.empty_intern_table (the string InternTable
// constructor): this builds the complexity-summary CostInternTable. The names
// must not collide — §3 single authority — so this module-local helper is named
// for the type it constructs. (The full compile→emit closure pulls in BOTH
// modules, so a shared `empty_intern_table` name was a real cross-module fork.)
fn empty_cost_intern_table() -> CostInternTable {
CostInternTable { summaries: empty_map() }
}

Expand Down Expand Up @@ -5091,7 +5096,7 @@ fn build_complexity_report(func_entries: List<FuncEntry>, recursion_ctx: Recursi
// evict its CostExpr tree — the working set is bounded by the maximum
// frontier width of the call graph, not the total function count.
let result = scc_result.processing_order |> fold(init: TopoBuildAcc {
table: empty_intern_table(),
table: empty_cost_intern_table(),
classes: empty_map(),
violations: [],
fan_in: fan_in,
Expand Down
59 changes: 59 additions & 0 deletions src/v1/probe_emit_interp.dag
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// EMISSION VERIFICATION PATH probe (not floor-enrolled) — answers the parent's
// ONE FACT: can the v2 interpreter RUN 05_emit_rust.dag's full compile→emit fold
// so that a .dag emitter change verifies BY INTERPRETATION, with no seed-mirror
// regen + cargo round trip?
//
// `gunbc run` resolves the live `src/v1` + `dsl` closure and INTERPRETS it. These
// probes drive `compile_sources(.., target: Rust)` — the same fold the seed
// emitter compiles — over an inline source and assert a property of the emitted
// Rust. Green here means: the emit fold is interpretation-runnable end to end
// (lex → parse → resolve → infer → emit), so every future emitter fix is
// checkable by running this path, not by hand-syncing the 22.7k-line Rust seed.
//
// Run (each returns Bool, ^claim-run prints the witness):
// gunbc run --source-root src/v1 --source-root dsl \
// --entry src/v1/probe_emit_interp.dag \
// --function probe_emit_fold_runs --claim-run
//
// The asserted properties are faithful to the CURRENT emitter (clean main) — they
// do not depend on any in-flight seed-emit change — so the probe isolates the
// interpreter-runs-the-fold fact from any particular emitter edit.
module v1.probe.emit_interp

import v1.compiler.compile { compile_sources, SourceFile }
import v1.compiler.artifact { RenderTarget, Rust }

// A minimal, self-contained source: one pure function over Int. Exercises the
// whole pipeline (tokenize → parse → resolve → infer → emit) yet stays small
// enough that the emitted signature is stable to assert against.
fn probe_source() -> SourceFile {
SourceFile {
path: "src/v1/probe_add.dag",
content: "module v1.probe.add\nfn add(x: Int, y: Int) -> Int {\n x + y\n}\n"
}
}

fn probe_emitted() -> String {
let result = compile_sources(sources: [probe_source()], target: Rust)
result.files |> map(f => f.content) |> join(separator: "\n")
}

// FACT 1 — the emit fold produces a non-empty Rust artifact through the
// interpreter (it ran to completion, not a partial/aborted fold).
fn probe_emit_fold_runs() -> Bool {
(probe_emitted() |> length) > 0
}

// FACT 2 — the emitted artifact carries the authored function as a Rust `fn add`
// signature. Discriminating: a wrong/partial emit (or a fold that bailed before
// the function emitter) makes this go RED.
fn probe_emits_fn_add() -> Bool {
contains(probe_emitted(), "fn add")
}

// FACT 3 — the pipeline reports zero diagnostics on a well-formed source, so a
// green emit is a true success, not a fail-open fabrication (DESIGN §5).
fn probe_clean_source_has_no_diagnostics() -> Bool {
let result = compile_sources(sources: [probe_source()], target: Rust)
(result.diagnostics |> count) == 0
}
138 changes: 126 additions & 12 deletions src/v1/stage0/src/v1_interpreter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2667,9 +2667,26 @@ fn eval_call(node: &Rc<Node>, env: &Rc<Env>, ctx: &InterpContext) -> InterpResul
let func_name = expr_call_func_at(node.clone(), ctx.si());
let arg_nodes = &node.children;

// Evaluate arguments
// SCAFFOLD (B-prime interim, marked per bright-stag D2 ruling 2026-06-22) —
// skip synthesized phantom TYPE-argument children. When resolve/infer
// monomorphizes a generic call (e.g. `empty_map()` whose result type is
// `Map<K, V>`), it attaches the type parameters as extra call children carrying
// kernel spans (`<kernel:K>`) and NO value child. A value argument always wraps
// its value (`arg_value` reads `children.first()`), so a zero-child arg node is
// never a value arg — it is a phantom type arg the untyped interpreter ignores.
// Filtering (rather than erroring in `arg_value`) is strictly more permissive: a
// zero-child node could not be evaluated as a value anyway.
//
// CLASSIFICATION: this is a SEPARATE type-erasure concern (phantom zero-child
// type-arg from `Map<K,V>` / generic-call monomorphization), NOT D2a
// (literals-as-Records / `eval_record_lit`). The two roots must not be conflated.
// DISSOLUTION TRIGGER: when the resolve→interp handoff carries type arguments in
// a dedicated node slot instead of mixing them into call value-arg children (so
// the interpreter never sees a phantom type-arg as a call child), this filter is
// unnecessary and is deleted. Until then it is a COUNTED GAP, not coverage.
let args: Vec<(Option<String>, Value)> = arg_nodes
.iter()
.filter(|arg_node| !arg_node.children.is_empty())
.map(|arg_node| {
let name = arg_name_at(arg_node.clone(), ctx.si());
let val = eval_expr(&arg_value(arg_node.clone()), env, ctx)?;
Expand Down Expand Up @@ -3524,6 +3541,33 @@ fn eval_algebra_method(
})
}

// list_push(list, item): append the argument as a SINGLE element, even
// when it is itself a list (unlike concat/append/push below, which merge
// a list-valued arg). Mirrors the free-function list_push so the same op
// resolves through method dispatch too; the lexer drives it. Additive.
"list_push" => {
if matches!(&receiver, Value::Str(_)) {
return Err(InterpError::TypeError {
msg: "list_push not supported on String".to_string(),
});
}
let item = args.first().cloned().unwrap_or(Value::Null);
match value_to_list_carrier(&receiver) {
Some((items, copied)) => {
let mut counters = ctx.mutation_counters.borrow_mut();
counters.list_push_calls += 1;
counters.list_push_items_copied += copied;
drop(counters);
let mut result = (*items).clone();
result.push_back(item);
Ok(list_value(result))
}
None => Err(InterpError::TypeError {
msg: format!("list_push on non-list: {}", receiver.type_label()),
}),
}
}

"concat" | "append" | "push" => {
// String concat preserves the String representation: a String IS a
// FreeMonoid<Char>, but its canonical value form is Value::Str — concat must
Expand Down Expand Up @@ -3624,9 +3668,16 @@ fn eval_algebra_method(
.iter()
.enumerate()
.map(|(i, v)| {
// Single authority: dsl/std/primitives.dag enumerate_contract
// declares `enumerate -> List<{first: Int, second: T}>` and
// the emit realization (languages.dag) maps `.first`→`.0`,
// `.second`→`.1` over a native tuple. The interpreter Pair
// must carry the SAME field names so `pair.first`/`pair.second`
// (the form every .dag consumer uses) resolve; the prior
// `index`/`value` names were a realization fork.
let mut fields = HashMap::new();
fields.insert(ctx.sym("index"), Value::Int(i as i64));
fields.insert(ctx.sym("value"), v.clone());
fields.insert(ctx.sym("first"), Value::Int(i as i64));
fields.insert(ctx.sym("second"), v.clone());
Value::Record {
type_name: ctx.sym("Pair"),
fields: Rc::new(fields),
Expand Down Expand Up @@ -3677,6 +3728,18 @@ fn eval_algebra_method(
Ok(Value::Str(strs.join(&sep)))
}

// chars(s) -> List<Int>: decompose a String into its Unicode code
// points (Ints), matching the emitted Rust template in
// languages.dag (`{recv}.chars().map(|c| c as i64).collect()`) and the
// lexer's `source_chars: List<Int>` consumer (01_tokenize). Free-call
// `chars(source)` desugars to receiver-style method dispatch here; the
// interpreter must implement it to run the full compile/emit fold.
"chars" => {
let s = expect_str(Some(&receiver), "chars")?;
let items: Vec<Value> = s.chars().map(|c| Value::Int(c as i64)).collect();
Ok(list_value(items))
}

// Map key lookup is checked BEFORE the FreeMonoid list path: a String IS a
// FreeMonoid<Char>, but `.get` on a String is not char-list indexing (ctrl#1476 B1;
// same Str-representation rule as `index` / `slice` / `contains`).
Expand Down Expand Up @@ -5311,6 +5374,39 @@ fn eval_builtin(
_ => Ok(None),
},

// chars_to_string(chars: List<Int>, start, end) -> String: slice a
// code-point list [start, end) into a String (v1_rt::chars_to_string /
// languages.dag). The lexer builds token text this way, so the
// interpreter needs it to run the full compile/emit fold.
"chars_to_string" => {
let cps = match positional.first().copied() {
Some(v) => free_monoid_to_vec(v).ok_or_else(|| InterpError::TypeError {
msg: "chars_to_string expects a list of code points".to_string(),
})?,
None => {
return Err(InterpError::TypeError {
msg: "chars_to_string requires a code-point list".to_string(),
})
}
};
let len = cps.len() as i64;
let start = expect_int(positional.get(1).copied(), "chars_to_string start")?
.max(0)
.min(len);
let end = expect_int(positional.get(2).copied(), "chars_to_string end")?
.max(0)
.min(len)
.max(start);
let s: String = cps[start as usize..end as usize]
.iter()
.filter_map(|v| match v {
Value::Int(cp) => char::from_u32(*cp as u32),
_ => None,
})
.collect();
Ok(Some(Value::Str(s)))
}

"parse_int" => {
let s = expect_str(positional.first().copied(), "parse_int")?;
match s.parse::<i64>() {
Expand All @@ -5319,6 +5415,13 @@ fn eval_builtin(
}
}

// Source-char cost metering primitive (runtime_rust.dag): a zero-arg
// side-effecting host fn the lexer (01_tokenize) calls directly. The
// compiled seed records a counter; the interpreter has no such counter,
// so it is a no-op returning unit. Needed for the interpreter to run
// the full compile/emit fold.
"record_source_chars_index_lookup" => Ok(Some(Value::Unit)),

"concat" => {
// Variadic string concat (common in .dag code)
if positional.len() >= 2 && positional.iter().all(|v| matches!(v, Value::Str(_))) {
Expand Down Expand Up @@ -6517,20 +6620,31 @@ fn raw_map_lookup(
},
Value::Record { fields, .. } | Value::Variant { fields, .. } => {
let lookup_sym = ctx.sym("lookup");
let lookup = fields
.get(&lookup_sym)
.ok_or_else(|| InterpError::TypeError {
msg: format!("raw_map_lookup expects Map, got {}", map.type_label()),
})?;
match lookup {
Value::Closure { .. } => apply_closure(lookup, &[key.clone()], env, ctx),
Value::Fn { node } => {
match fields.get(&lookup_sym) {
// A lookup-table abstraction carries a callable `lookup` field.
Some(lookup @ Value::Closure { .. }) => {
apply_closure(lookup, &[key.clone()], env, ctx)
}
Some(Value::Fn { node }) => {
let named = vec![(None, key.clone())];
call_function(ctx, node, &named, env)
}
_ => Err(InterpError::TypeError {
Some(_) => Err(InterpError::TypeError {
msg: "Map.lookup field is not callable".to_string(),
}),
// Record-form map (a `data x: Map<K,V> = { ... }` literal, which
// the interpreter builds as a Record): look the key up as a
// field name. A miss yields Null, which the Witness/Optional
// bridge projects to Violates/Absent (see eval_match_pattern's
// record-form empty_map handling). Additive: this case
// previously errored.
None => match key {
Value::Str(s) => {
let k = ctx.sym(s);
Ok(fields.get(&k).cloned().unwrap_or(Value::Null))
}
_ => Ok(Value::Null),
},
}
}
_ => Err(InterpError::TypeError {
Expand Down