diff --git a/Cargo.lock b/Cargo.lock index 8802b998c60..9caaa2ce4bb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -94,6 +94,15 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bitmaps" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031043d04099746d8db04daf1fa424b2bc8bd69d92b25962dcde24da39ab64a2" +dependencies = [ + "typenum", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -569,6 +578,20 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "im-rc" +version = "15.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1955a75fa080c677d3972822ec4bad316169ab1cfc6c257a942c2265dbe5fe" +dependencies = [ + "bitmaps", + "rand_core", + "rand_xoshiro", + "sized-chunks", + "typenum", + "version_check", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -795,6 +818,21 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "rand_xoshiro" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f97cdb2a36ed4183de61b2f824cc45c9f1037f28afe0a322e9fff4c108b5aaa" +dependencies = [ + "rand_core", +] + [[package]] name = "rayon" version = "1.12.0" @@ -1002,6 +1040,16 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "sized-chunks" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16d69225bde7a69b235da73377861095455d298f2b970996eec25ddbb42b3d1e" +dependencies = [ + "bitmaps", + "typenum", +] + [[package]] name = "slab" version = "0.4.12" @@ -1199,6 +1247,7 @@ name = "v2-compiler" version = "0.1.0" dependencies = [ "clap", + "im-rc", "lazy_static", "serde", "serde_json", diff --git a/src/v2/stage0/Cargo.toml b/src/v2/stage0/Cargo.toml index bc42fd92040..15bc4aecaa8 100644 --- a/src/v2/stage0/Cargo.toml +++ b/src/v2/stage0/Cargo.toml @@ -8,6 +8,14 @@ default = [] text_lookup_work_counter = [] [dependencies] +# Persistent value carriers (ctrl#1533 phase 2). im-rc is the Rc-backed +# (thread-confined, matching the Value Rc-discipline) crate providing BOTH +# carriers v4.std.value_carrier declares: HashMap is a hash array mapped trie +# (map_carrier: ^hash_array_mapped_trie) and Vector is a relaxed radix +# balanced tree (list_carrier: ^relaxed_radix_balanced_tree, O(log n) +# concatenation). rpds was the considered alternative; rejected because its +# Vector has no concatenation, so it cannot realize the declared list carrier. +im-rc = "15.1" stacker = "0.1" clap = { version = "4", features = ["derive"] } lazy_static = "1" diff --git a/src/v2/stage0/src/v2_interpreter.rs b/src/v2/stage0/src/v2_interpreter.rs index fb2cd38dcfe..23d3b073220 100644 --- a/src/v2/stage0/src/v2_interpreter.rs +++ b/src/v2/stage0/src/v2_interpreter.rs @@ -7,6 +7,15 @@ use std::fmt; use std::hash::{Hash, Hasher}; use std::rc::Rc; +// Persistent value carriers (ctrl#1533 phase 2), implementing the +// v4.std.value_carrier declarations: HamtMap is a hash array mapped trie +// (map_carrier), RrbVector a relaxed radix balanced tree (list_carrier). +// Both are Rc-backed (im-rc), matching the thread-confined Rc-discipline of +// Value. Updates share structure with the prior version instead of copying +// it — the quadratic-allocation term ctrl#1533 phase 0 measured. +use im_rc::HashMap as HamtMap; +use im_rc::Vector as RrbVector; + use crate::std_syntax::BinOp; use crate::std_syntax::LiteralValue; use crate::v2_compiler_emit::{extract_string_interp_parts, has_mock_prefix}; @@ -258,8 +267,13 @@ pub enum Value { Int(i64), Float(f64), Str(String), - List(Rc>), - Map(Rc>), + // List/Map keep an Rc handle around the persistent carrier: Rc::as_ptr + // remains the value-identity used by pure-call memo keys + // (value_rc_identity) and the accounting walker's sharing dedup. The + // carrier inside the Rc shares structure across versions; the handle + // clone needed before an update is O(1). + List(Rc>), + Map(Rc>), /// String membership sets (`Set` in .dag). Set(Rc>), Record { @@ -283,6 +297,17 @@ pub enum Value { Unit, } +/// Wrap a list carrier (or anything convertible to one, e.g. `Vec`) +/// into a `Value::List`. +fn list_value(items: impl Into>) -> Value { + Value::List(Rc::new(items.into())) +} + +/// Wrap a map carrier into a `Value::Map`. +fn map_value(entries: HamtMap) -> Value { + Value::Map(Rc::new(entries)) +} + impl Value { /// Public name of this value's kind (e.g. "List", "Record", "Variant"), /// for host diagnostics that walk interpreter values (see `claim_executor`). @@ -1155,7 +1180,7 @@ fn eval_expr_inner(node: &Rc, env: &Rc, ctx: &InterpContext) -> Inter .iter() .map(|child| eval_expr(child, env, ctx)) .collect::>()?; - Ok(Value::List(Rc::new(items))) + Ok(list_value((items))) } ExprData::ExprLambda => { @@ -1331,7 +1356,7 @@ fn eval_binop(op: &BinOp, left: Value, right: Value, ctx: &InterpContext) -> Int if let Some(mut result) = free_monoid_to_vec(l) { record_push(result.len()); result.push(Value::Str(s.clone())); - return Ok(Value::List(Rc::new(result))); + return Ok(list_value((result))); } } (Value::Str(s), r) => { @@ -1339,7 +1364,7 @@ fn eval_binop(op: &BinOp, left: Value, right: Value, ctx: &InterpContext) -> Int record_push(result.len()); let mut out = vec![Value::Str(s.clone())]; out.extend(result); - return Ok(Value::List(Rc::new(out))); + return Ok(list_value((out))); } } _ => { @@ -1351,7 +1376,7 @@ fn eval_binop(op: &BinOp, left: Value, right: Value, ctx: &InterpContext) -> Int counters.list_concat_items_copied += (a.len() + b.len()) as u64; drop(counters); a.extend(b); - return Ok(Value::List(Rc::new(a))); + return Ok(list_value((a))); } } } @@ -1721,7 +1746,10 @@ fn match_pattern( None } else { let head = items[0].clone(); - let tail = Value::List(Rc::new(items[1..].to_vec())); + let tail = { + let mut rest = (**items).clone(); + list_value(rest.split_off(1)) + }; let mut bindings = HashMap::new(); for fb in field_bindings.iter() { let field_name = @@ -2098,7 +2126,7 @@ fn eval_field_access( // Map entry: (key, value).first → key // Or list: first element match expect_list(&base_val, "tuple.first") { - Ok(items) => Ok(items.first().cloned().unwrap_or(Value::Null)), + Ok(items) => Ok(items.front().cloned().unwrap_or(Value::Null)), Err(_) => extract_field(&base_val, &field_name, env, ctx), } } @@ -2243,7 +2271,7 @@ fn eval_for_each(node: &Rc, env: &Rc, ctx: &InterpContext) -> InterpR let iter_env = Env::with_binding(env, var_name.clone(), item.clone()); results.push(eval_expr(&body_node, &iter_env, ctx)?); } - Ok(Value::List(Rc::new(results))) + Ok(list_value((results))) } // --------------------------------------------------------------------------- @@ -2302,7 +2330,8 @@ fn eval_slice(node: &Rc, env: &Rc, ctx: &InterpContext) -> InterpResu let items = expect_list(base_val, "slice")?; let s = *s as usize; let e = (*e as usize).min(items.len()); - Ok(Value::List(Rc::new(items[s..e].to_vec()))) + let mut work = (*items).clone(); + Ok(list_value(work.slice(s..e))) } (Value::Str(str_val), Value::Int(s), Value::Int(e)) => { let s = *s as usize; @@ -2346,7 +2375,7 @@ fn eval_algebra_method( .iter() .map(|item| apply_closure(f, &[item.clone()], env, ctx)) .collect::>>() - .map(|v| Value::List(Rc::new(v))) + .map(|v| list_value((v))) }), "filter" => { @@ -2358,7 +2387,7 @@ fn eval_algebra_method( result.push(item.clone()); } } - Ok(Value::List(Rc::new(result))) + Ok(list_value((result))) }) } @@ -2399,7 +2428,7 @@ fn eval_algebra_method( } } } - Ok(Value::List(Rc::new(result))) + Ok(list_value((result))) }, ), @@ -2431,9 +2460,9 @@ fn eval_algebra_method( }) .collect::>()?; keyed.sort_by(|(ka, _), (kb, _)| cmp_values(ka, kb)); - Ok(Value::List(Rc::new( - keyed.into_iter().map(|(_, v)| v).collect(), - ))) + Ok(list_value( + keyed.into_iter().map(|(_, v)| v).collect::>(), + )) }) } @@ -2449,38 +2478,41 @@ fn eval_algebra_method( return Ok(Value::Str(result)); } if let Ok(items) = expect_list(&receiver, "concat") { - let receiver_len = items.len(); - let mut result = items.to_vec(); + let mut result = (*items).clone(); // Counter bucket is decided by what the args ARE, not which // method name dispatched here (see `MutationCounters`): a // flattening collection arg merges (concat: both operands' // elements are copy-work), atomic args append one element each - // (push: receiver elements only). + // (push: receiver elements only). Under the persistent carrier + // (ctrl#1533 phase 2) native-List operands concatenate by + // structural sharing, so *_items_copied counts only elements + // actually copied through the FreeMonoid flatten. let mut merged_items = 0usize; + let mut copied_items = 0usize; for arg in args { // Non-list Str args append as one element, not char-exploded (ctrl#1476 B1). if matches!(arg, Value::Str(_)) { - result.push(arg.clone()); + result.push_back(arg.clone()); } else { - match free_monoid_to_vec(arg) { - Some(other) => { + match value_to_list_carrier(arg) { + Some((other, copied)) => { merged_items += other.len(); - result.extend(other); + copied_items += copied as usize; + result.append((*other).clone()); } - None => result.push(arg.clone()), + None => result.push_back(arg.clone()), } } } let mut counters = ctx.mutation_counters.borrow_mut(); if merged_items > 0 { counters.list_concat_calls += 1; - counters.list_concat_items_copied += (receiver_len + merged_items) as u64; + counters.list_concat_items_copied += copied_items as u64; } else { counters.list_push_calls += 1; - counters.list_push_items_copied += receiver_len as u64; } drop(counters); - return Ok(Value::List(Rc::new(result))); + return Ok(list_value(result)); } Err(InterpError::TypeError { msg: format!("cannot concat on {}", receiver.type_label()), @@ -2499,7 +2531,7 @@ fn eval_algebra_method( "first" => { let items = expect_list(&receiver, "first")?; - Ok(items.first().cloned().unwrap_or(Value::Null)) + Ok(items.front().cloned().unwrap_or(Value::Null)) } "last" => { @@ -2509,25 +2541,23 @@ fn eval_algebra_method( "reverse" => { let items = expect_list(&receiver, "reverse")?; - let mut result = items.to_vec(); - result.reverse(); - Ok(Value::List(Rc::new(result))) + Ok(list_value(items.iter().rev().cloned().collect::>())) } "skip" => { let items = expect_list(&receiver, "skip")?; let n = expect_int(args.first(), "skip")?; - Ok(Value::List(Rc::new( - items.iter().skip(n as usize).cloned().collect(), - ))) + Ok(list_value( + items.iter().skip(n as usize).cloned().collect::>(), + )) } "take" => { let items = expect_list(&receiver, "take")?; let n = expect_int(args.first(), "take")?; - Ok(Value::List(Rc::new( - items.iter().take(n as usize).cloned().collect(), - ))) + Ok(list_value( + items.iter().take(n as usize).cloned().collect::>(), + )) } "enumerate" => { @@ -2545,7 +2575,7 @@ fn eval_algebra_method( } }) .collect(); - Ok(Value::List(Rc::new(result))) + Ok(list_value((result))) } // String/Map membership is checked BEFORE the FreeMonoid list path: a String IS a @@ -2622,39 +2652,38 @@ fn eval_algebra_method( let ck = CanonKey::new(key).ok_or_else(|| InterpError::TypeError { msg: "insert key is not a valid map key (closure/fn/NaN)".to_string(), })?; + // Persistent update (ctrl#1533 phase 2): O(log32 n) path copy with + // structural sharing — no entries are copied, so the + // *_entries_copied counter is not incremented. let mut counters = ctx.mutation_counters.borrow_mut(); counters.map_insert_calls += 1; - counters.map_insert_entries_copied += m.len() as u64; drop(counters); - let mut new_map = HashMap::clone(&m); - new_map.insert(ck, val); - Ok(Value::Map(Rc::new(new_map))) + Ok(map_value(m.update(ck, val))) } "merge" => { let base = expect_map(&receiver, "merge")?; let overlay = expect_map(args.first().unwrap_or(&Value::Null), "merge")?; + // Persistent merge (ctrl#1533 phase 2): structural union sharing + // unchanged subtrees — no entries are copied. im union keeps + // self's value on key collision, so overlay-as-self preserves the + // overlay-wins (last-write-wins) semantics. let mut counters = ctx.mutation_counters.borrow_mut(); counters.map_merge_calls += 1; - counters.map_merge_entries_copied += (base.len() + overlay.len()) as u64; drop(counters); - let mut result = HashMap::clone(&base); - for (k, v) in overlay.iter() { - result.insert(k.clone(), v.clone()); - } - Ok(Value::Map(Rc::new(result))) + Ok(map_value((*overlay).clone().union((*base).clone()))) } "keys" => { let m = expect_map(&receiver, "keys")?; let keys: Vec = m.keys().map(|k| k.key.clone()).collect(); - Ok(Value::List(Rc::new(keys))) + Ok(list_value((keys))) } "values" => { let m = expect_map(&receiver, "values")?; let vals: Vec = m.values().cloned().collect(); - Ok(Value::List(Rc::new(vals))) + Ok(list_value((vals))) } // String-specific methods @@ -2676,7 +2705,7 @@ fn eval_algebra_method( let s = expect_string(&receiver, "split")?; let sep = expect_str(args.first(), "split")?; let parts: Vec = s.split(&sep).map(|p| Value::Str(p.to_string())).collect(); - Ok(Value::List(Rc::new(parts))) + Ok(list_value((parts))) } "trim" => { @@ -2731,7 +2760,7 @@ fn eval_algebra_method( env, ctx, |items, f, env, ctx| { - let mut m = HashMap::new(); + let mut m = HamtMap::new(); for item in items.iter() { let key = apply_closure(f, &[item.clone()], env, ctx)?; let ck = CanonKey::new(key).ok_or_else(|| InterpError::TypeError { @@ -2739,7 +2768,7 @@ fn eval_algebra_method( })?; m.insert(ck, item.clone()); } - Ok(Value::Map(Rc::new(m))) + Ok(map_value(m)) }, ), @@ -2927,7 +2956,7 @@ fn map_shell_outputs( .lines() .map(|l| Value::Str(l.to_string())) .collect(); - Value::List(Rc::new(lines)) + list_value((lines)) } _ => { // Default: map by field name @@ -3467,16 +3496,16 @@ fn json_to_value(json: &serde_json::Value) -> Value { } serde_json::Value::String(s) => Value::Str(s.clone()), serde_json::Value::Array(arr) => { - Value::List(Rc::new(arr.iter().map(json_to_value).collect())) + list_value(arr.iter().map(json_to_value).collect::>()) } serde_json::Value::Object(obj) => { - let fields: HashMap = obj + let fields: HamtMap = obj .iter() .filter_map(|(k, v)| { CanonKey::new(Value::Str(k.clone())).map(|ck| (ck, json_to_value(v))) }) .collect(); - Value::Map(Rc::new(fields)) + map_value(fields) } } } @@ -3559,7 +3588,7 @@ fn eval_builtin( Some(mut result) => { record_push(result.len()); result.push(Value::Str(s.clone())); - Ok(Some(Value::List(Rc::new(result)))) + Ok(Some(list_value((result)))) } None => Ok(None), }, @@ -3568,7 +3597,7 @@ fn eval_builtin( record_push(result.len()); let mut out = vec![Value::Str(s.clone())]; out.extend(result); - Ok(Some(Value::List(Rc::new(out)))) + Ok(Some(list_value((out)))) } None => Ok(None), }, @@ -3580,7 +3609,7 @@ fn eval_builtin( (a_items.len() + b_items.len()) as u64; drop(counters); a_items.extend(b_items); - Ok(Some(Value::List(Rc::new(a_items)))) + Ok(Some(list_value((a_items)))) } _ => Ok(None), }, @@ -3605,7 +3634,7 @@ fn eval_builtin( Some(items) => { let mut r = items; r.reverse(); - Ok(Some(Value::List(Rc::new(r)))) + Ok(Some(list_value((r)))) } None => Ok(None), }, @@ -3668,15 +3697,18 @@ fn eval_builtin( "list_push" | "append" => match positional.as_slice() { [list_val, item] if matches!(list_val, Value::Str(_)) => Ok(None), - [list_val, item] => match free_monoid_to_vec(list_val) { - Some(items) => { + // Persistent O(log n) push_back (ctrl#1533 phase 2): a native + // List shares structure with the prior version — only a chain + // form copies (through the flatten), and only that is counted. + [list_val, item] => match value_to_list_carrier(list_val) { + Some((items, copied)) => { let mut counters = ctx.mutation_counters.borrow_mut(); counters.list_push_calls += 1; - counters.list_push_items_copied += items.len() as u64; + counters.list_push_items_copied += copied; drop(counters); - let mut result = items; - result.push((*item).clone()); - Ok(Some(Value::List(Rc::new(result)))) + let mut result = (*items).clone(); + result.push_back((*item).clone()); + Ok(Some(list_value(result))) } None => Ok(None), }, @@ -3685,22 +3717,25 @@ fn eval_builtin( "list_concat" => match positional.as_slice() { [a, b] if matches!(a, Value::Str(_)) || matches!(b, Value::Str(_)) => Ok(None), - [a, b] => match (free_monoid_to_vec(a), free_monoid_to_vec(b)) { - (Some(a_items), Some(b_items)) => { + // Persistent O(log(n+m)) RRB concatenation (ctrl#1533 phase 2): + // native Lists share structure — only chain-form operands copy + // (through the flatten), and only those items are counted. + [a, b] => match (value_to_list_carrier(a), value_to_list_carrier(b)) { + (Some((a_items, a_copied)), Some((b_items, b_copied))) => { let mut counters = ctx.mutation_counters.borrow_mut(); counters.list_concat_calls += 1; - counters.list_concat_items_copied += (a_items.len() + b_items.len()) as u64; + counters.list_concat_items_copied += a_copied + b_copied; drop(counters); - let mut result = a_items; - result.extend(b_items); - Ok(Some(Value::List(Rc::new(result)))) + let mut result = (*a_items).clone(); + result.append((*b_items).clone()); + Ok(Some(list_value(result))) } _ => Ok(None), }, _ => Ok(None), }, - "empty_map" => Ok(Some(Value::Map(Rc::new(HashMap::new())))), + "empty_map" => Ok(Some(map_value(HamtMap::new()))), "empty_set" => Ok(Some(Value::Set(Rc::new(BTreeSet::new())))), @@ -3746,13 +3781,12 @@ fn eval_builtin( // "not this builtin shape" (a non-Map receiver). [Value::Map(m), k, v] => match CanonKey::new((*k).clone()) { Some(ck) => { + // Persistent update (ctrl#1533 phase 2): O(log32 n) path + // copy with structural sharing — no entries are copied. let mut counters = ctx.mutation_counters.borrow_mut(); counters.map_insert_calls += 1; - counters.map_insert_entries_copied += m.len() as u64; drop(counters); - let mut result = HashMap::clone(m); - result.insert(ck, (*v).clone()); - Ok(Some(Value::Map(Rc::new(result)))) + Ok(Some(map_value(m.update(ck, (*v).clone())))) } None => Err(InterpError::TypeError { msg: format!( @@ -3781,7 +3815,7 @@ fn eval_builtin( "map_keys" => match positional.first() { Some(Value::Map(m)) => { let keys: Vec = m.keys().map(|k| k.key.clone()).collect(); - Ok(Some(Value::List(Rc::new(keys)))) + Ok(Some(list_value((keys)))) } _ => Ok(None), }, @@ -3789,7 +3823,7 @@ fn eval_builtin( "map_values" => match positional.first() { Some(Value::Map(m)) => { let vals: Vec = m.values().cloned().collect(); - Ok(Some(Value::List(Rc::new(vals)))) + Ok(Some(list_value((vals)))) } _ => Ok(None), }, @@ -3804,15 +3838,13 @@ fn eval_builtin( "map_merge" => match positional.as_slice() { [Value::Map(base), Value::Map(overlay)] => { + // Persistent merge (ctrl#1533 phase 2): structural union, no + // entry copies. im union keeps self's value on key collision, + // so overlay-as-self preserves overlay-wins semantics. let mut counters = ctx.mutation_counters.borrow_mut(); counters.map_merge_calls += 1; - counters.map_merge_entries_copied += (base.len() + overlay.len()) as u64; drop(counters); - let mut result = HashMap::clone(&base); - for (k, v) in overlay.iter() { - result.insert(k.clone(), v.clone()); - } - Ok(Some(Value::Map(Rc::new(result)))) + Ok(Some(map_value((**overlay).clone().union((**base).clone())))) } _ => Ok(None), }, @@ -3894,7 +3926,7 @@ fn list_method_with_closure( f: F, ) -> InterpResult where - F: FnOnce(&Rc>, &Value, &Rc, &InterpContext) -> InterpResult, + F: FnOnce(&RrbVector, &Value, &Rc, &InterpContext) -> InterpResult, { let items = expect_list(&receiver, method_name)?; let closure = args.first().ok_or_else(|| InterpError::TypeError { @@ -3972,11 +4004,27 @@ fn free_monoid_to_vec(val: &Value) -> Option> { } } -fn expect_list(val: &Value, context: &str) -> InterpResult>> { +/// Bridge a list-denoting value (native List, FreeMonoid Variant chain, or +/// Str-as-FreeMonoid) to the list carrier, honoring the B1 alias +/// transparency through ONE code path. A native List hands back its carrier +/// handle without copying; chain forms flatten through free_monoid_to_vec — +/// the second tuple element reports how many items that flatten copied (the +/// phase-0 *_items_copied accounting). Non-list values return None. +fn value_to_list_carrier(val: &Value) -> Option<(Rc>, u64)> { + match val { + Value::List(items) => Some((items.clone(), 0)), + _ => free_monoid_to_vec(val).map(|items| { + let copied = items.len() as u64; + (Rc::new(RrbVector::from(items)), copied) + }), + } +} + +fn expect_list(val: &Value, context: &str) -> InterpResult>> { match val { Value::List(items) => Ok(items.clone()), _ => match free_monoid_to_vec(val) { - Some(items) => Ok(Rc::new(items)), + Some(items) => Ok(Rc::new(RrbVector::from(items))), None => Err(InterpError::TypeError { msg: format!("{} expects a list, got {}", context, val.type_label()), }), @@ -4033,7 +4081,7 @@ fn raw_map_lookup( } } -fn expect_map(val: &Value, context: &str) -> InterpResult>> { +fn expect_map(val: &Value, context: &str) -> InterpResult>> { match val { Value::Map(m) => Ok(m.clone()), _ => Err(InterpError::TypeError { diff --git a/src/v2/tests/src/data_cache_scoping_test.rs b/src/v2/tests/src/data_cache_scoping_test.rs index 46a1d80aa00..d62f7142ca8 100644 --- a/src/v2/tests/src/data_cache_scoping_test.rs +++ b/src/v2/tests/src/data_cache_scoping_test.rs @@ -117,8 +117,8 @@ fn read_xs() -> List { xs } let second = v2_interpreter::run_in_context(&ctx2, "read_xs", false).expect("second context"); match (&first, &second) { (Value::List(a), Value::List(b)) => { - assert_eq!(a.as_slice(), &[Value::Int(7)]); - assert_eq!(b.as_slice(), &[Value::Int(7)]); + assert_eq!(a.iter().cloned().collect::>(), vec![Value::Int(7)]); + assert_eq!(b.iter().cloned().collect::>(), vec![Value::Int(7)]); } other => panic!("expected two Lists, got {other:?}"), } diff --git a/src/v2/tests/src/interp_stats_test.rs b/src/v2/tests/src/interp_stats_test.rs index 11e026cbb89..d968f1940d0 100644 --- a/src/v2/tests/src/interp_stats_test.rs +++ b/src/v2/tests/src/interp_stats_test.rs @@ -1,11 +1,13 @@ //! Phase-0 measurement harness (ctrl#1533): the interpreter counts the copy -//! work its copy-on-update collection primitives perform, and can produce a -//! sharing-aware byte accounting of what a context retains. +//! work its collection primitives perform, and can produce a sharing-aware +//! byte accounting of what a context retains. //! -//! These tests pin the measurement semantics, not the performance: fold-built -//! collections must show the triangular-number copy term (the quadratic -//! signature the persistent-carrier work removes), and shared structure must -//! be accounted exactly once. +//! These tests pin the measurement semantics, not the performance. Under the +//! phase-2 persistent carriers, native-carrier updates share structure and +//! copy NOTHING — the *_entries_copied/_items_copied counters must read 0 +//! (the triangular-number copy term phase 0 measured is gone; what remains +//! countable is the FreeMonoid chain flatten). Shared structure must be +//! accounted exactly once. use std::rc::Rc; @@ -37,11 +39,11 @@ fn resolve(src: &str) -> Rc { resolved } -/// Building a 4-entry map by successive inserts copies 0+1+2+3 = 6 entries — -/// the triangular-number term. Per-call average grows with collection size; -/// that ratio is the quadratic-allocation receipt. +/// Building a 4-entry map by successive inserts performs 4 persistent +/// updates and copies ZERO entries — the triangular-number term (0+1+2+3 = 6 +/// under the ephemeral carrier) is gone. This is the phase-2 receipt. #[test] -fn fold_built_map_counts_triangular_copy_work() { +fn fold_built_map_copies_no_entries() { let src = r#"module test.stats_map fn build() -> Int { let m = empty_map() |> map_insert("a", 1) |> map_insert("b", 2) |> map_insert("c", 3) |> map_insert("d", 4) @@ -58,15 +60,14 @@ fn build() -> Int { } let counters = ctx.mutation_counters_snapshot(); assert_eq!(counters.map_insert_calls, 4); - assert_eq!(counters.map_insert_entries_copied, 6); + assert_eq!(counters.map_insert_entries_copied, 0); } -/// Concat is a merge: BOTH operands' elements are copy-work (every element of -/// the result is a copy of a pre-existing collection member). [1,2,3]⊕[4] -/// copies 4, then the 4-list⊕[5] copies 5 — the same triangular growth on the -/// list side. +/// Concat of native lists is persistent RRB concatenation: both calls are +/// counted as merges, but no items are copied (under the ephemeral carrier +/// [1,2,3]⊕[4] copied 4 and the 4-list⊕[5] copied 5 — triangular growth). #[test] -fn list_concat_counts_both_operands() { +fn list_concat_copies_no_native_items() { let src = r#"module test.stats_list fn build() -> Int { let a = [1, 2, 3].concat([4]) @@ -84,14 +85,14 @@ fn build() -> Int { } let counters = ctx.mutation_counters_snapshot(); assert_eq!(counters.list_concat_calls, 2); - assert_eq!(counters.list_concat_items_copied, 9); + assert_eq!(counters.list_concat_items_copied, 0); assert_eq!(counters.list_push_calls, 0, "merge must not leak into push"); } -/// Appending an atomic element is a push regardless of dispatch surface: only -/// the receiver's pre-existing elements are copy-work (the new element must be -/// written under any carrier). Same primitive, one bucket — never split -/// between `list_push` and `list_concat` by method-vs-builtin path. +/// Appending an atomic element is a push regardless of dispatch surface, and +/// a persistent push_back copies none of the receiver's elements. Same +/// primitive, one bucket — never split between `list_push` and `list_concat` +/// by method-vs-builtin path. #[test] fn atomic_append_counts_as_push_not_concat() { let src = r#"module test.stats_push @@ -110,7 +111,7 @@ fn build() -> Int { } let counters = ctx.mutation_counters_snapshot(); assert_eq!(counters.list_push_calls, 1); - assert_eq!(counters.list_push_items_copied, 3); + assert_eq!(counters.list_push_items_copied, 0); assert_eq!( counters.list_concat_calls, 0, "push must not leak into concat" diff --git a/src/v2/tests/src/lib.rs b/src/v2/tests/src/lib.rs index 639d517dec1..d2ade661ce5 100644 --- a/src/v2/tests/src/lib.rs +++ b/src/v2/tests/src/lib.rs @@ -62,4 +62,6 @@ mod sub_value_lattice_factor_test; #[cfg(test)] mod v2_compiler_lib_test; #[cfg(test)] +mod value_carrier_swap_test; +#[cfg(test)] mod witness_option_bridge_test; diff --git a/src/v2/tests/src/value_carrier_swap_test.rs b/src/v2/tests/src/value_carrier_swap_test.rs new file mode 100644 index 00000000000..ccf61b98ffb --- /dev/null +++ b/src/v2/tests/src/value_carrier_swap_test.rs @@ -0,0 +1,140 @@ +//! ctrl#1533 phase 2 — persistent-carrier swap probes. +//! +//! The v4.std.value_carrier sharing/identity laws have executable witnesses +//! in src/v4/test/claim/std_grounding/value_carrier_laws.dag; those run at +//! small n. These probes cover what the witnesses cannot: collection sizes +//! past the carriers' inline-chunk thresholds (so HAMT/RRB tree nodes are +//! actually exercised), prior-version validity after derived updates (the +//! Driscoll et al. persistence property — the failure class a carrier swap +//! can introduce is in-place mutation observed through a shared handle), and +//! reference-identity non-observability checked from the host side. + +use std::rc::Rc; + +use v2_compiler::v2_compiler_compile::{compile_to_resolved, ResolvedPipelineResult}; +use v2_compiler::v2_interpreter::{self, Value}; + +use crate::helpers::resolve_imports_transitively; + +/// Past im-rc's 64-element inline chunks, so both carriers run their tree +/// (non-inline) code paths. +const SCALE: usize = 500; + +fn assert_resolved_no_hard_errors(result: &ResolvedPipelineResult) { + let msgs: Vec = result + .diagnostics + .iter() + .map(|d| v2_compiler::v2_std_core::diagnostic_to_message(d.diagnostic.clone())) + .filter(|m| !m.starts_with("complexity: ")) + .collect(); + assert!( + msgs.is_empty() && result.graph.is_some(), + "expected resolved graph, got diagnostics {:?} (graph present: {})", + msgs, + result.graph.is_some() + ); +} + +fn run_bool(src: &str, entry: &str) { + let sources = resolve_imports_transitively("test.dag", src); + let resolved = compile_to_resolved(Rc::new(sources)); + assert_resolved_no_hard_errors(&resolved); + let graph = resolved.graph.as_ref().expect("graph"); + match v2_interpreter::run(graph, resolved.source_indices.clone(), entry) { + Ok(Value::Bool(true)) => {} + other => panic!("expected Bool(true) from {entry}, got {other:?}"), + } +} + +fn int_literal_list(range: impl Iterator) -> String { + range.map(|i| i.to_string()).collect::>().join(", ") +} + +/// Map equality is insert-order independent past the inline-chunk threshold: +/// the same entry set fold-built ascending and descending yields equal maps. +#[test] +fn map_equality_insert_order_independent_at_scale() { + let up = int_literal_list(1..=SCALE); + let down = int_literal_list((1..=SCALE).rev()); + let src = format!( + r#"module test.carrier_scale_map +fn order_independent() -> Bool {{ + let ascending = fold([{up}], init: empty_map(), f: fn(acc, x) {{ map_insert(acc, x, x) }}) + let descending = fold([{down}], init: empty_map(), f: fn(acc, x) {{ map_insert(acc, x, x) }}) + ascending == descending +}} +"# + ); + run_bool(&src, "order_independent"); +} + +/// Overwrite is last-write-wins independent of scale: re-inserting every key +/// with a new value equals building the final entry set directly. +#[test] +fn map_overwrite_path_independent_at_scale() { + let keys = int_literal_list(1..=SCALE); + let src = format!( + r#"module test.carrier_scale_overwrite +fn overwrite_wins() -> Bool {{ + let stale = fold([{keys}], init: empty_map(), f: fn(acc, x) {{ map_insert(acc, x, 0) }}) + let overwritten = fold([{keys}], init: stale, f: fn(acc, x) {{ map_insert(acc, x, x) }}) + let direct = fold([{keys}], init: empty_map(), f: fn(acc, x) {{ map_insert(acc, x, x) }}) + overwritten == direct +}} +"# + ); + run_bool(&src, "overwrite_wins"); +} + +/// Persistence (Driscoll et al. 1989): a derived version leaves the prior +/// version valid. `extended`/`updated` are derived FROM `base`; `base` must +/// still equal an independently built structural twin afterwards. An +/// in-place-mutation bug through a shared carrier handle fails this. +#[test] +fn prior_versions_survive_derived_updates_at_scale() { + let nums = int_literal_list(1..=SCALE); + let src = format!( + r#"module test.carrier_persistence +fn prior_versions_valid() -> Bool {{ + let base_list = [{nums}] + let extended = concat(base_list, [0]) + let base_map = fold([{nums}], init: empty_map(), f: fn(acc, x) {{ map_insert(acc, x, x) }}) + let updated = map_insert(base_map, 1, 0) + let list_intact = base_list == [{nums}] + let map_intact = base_map == fold([{nums}], init: empty_map(), f: fn(acc, x) {{ map_insert(acc, x, x) }}) + let derived_differ = !(extended == base_list) && !(updated == base_map) + list_intact && map_intact && derived_differ +}} +"# + ); + run_bool(&src, "prior_versions_valid"); +} + +/// Reference identity is not observable through equality (M-C law 1 stays +/// one-way): two separately evaluated runs build pointer-distinct values +/// that compare equal from the host side. +#[test] +fn equality_does_not_depend_on_reference_identity() { + let nums = int_literal_list(1..=SCALE); + let src = format!( + r#"module test.carrier_identity +fn build() -> List {{ concat([{nums}], [0]) }} +"# + ); + let sources = resolve_imports_transitively("test.dag", &src); + let resolved = compile_to_resolved(Rc::new(sources)); + assert_resolved_no_hard_errors(&resolved); + let graph = resolved.graph.as_ref().expect("graph"); + let first = + v2_interpreter::run(graph, resolved.source_indices.clone(), "build").expect("first build"); + let second = + v2_interpreter::run(graph, resolved.source_indices.clone(), "build").expect("second build"); + match (&first, &second) { + (Value::List(a), Value::List(b)) => { + assert!(!Rc::ptr_eq(a, b), "two runs must build distinct handles"); + assert_eq!(a.len(), SCALE + 1); + } + other => panic!("expected two Lists, got {other:?}"), + } + assert_eq!(first, second, "pointer-distinct twins must compare equal"); +} diff --git a/src/v4/test/claim/std_grounding/value_carrier_laws.dag b/src/v4/test/claim/std_grounding/value_carrier_laws.dag index a70e5c2b2f7..c7e1a5196ea 100644 --- a/src/v4/test/claim/std_grounding/value_carrier_laws.dag +++ b/src/v4/test/claim/std_grounding/value_carrier_laws.dag @@ -114,6 +114,29 @@ fn name_interning_covers_all_domains_holds() -> Bool { t && v && f } +// ── Persistence law (M-A, phase-2 swap probe): prior versions stay valid ─ +// Driscoll–Sarnak–Sleator–Tarjan 1989: a Persistent carrier leaves every +// prior version valid after an update. `extended`/`updated` are derived FROM +// the base values; the bases must still equal independently built structural +// twins afterwards. The failure class a carrier swap can introduce is +// in-place mutation observed through a shared handle — this is its witness. +// Large-n twins (past the carriers' inline-chunk thresholds) run host-side +// in src/v2/tests/src/value_carrier_swap_test.rs. + +fn list_prior_version_survives_concat_holds() -> Bool { + let base = [1, 2, 3] + let extended = concat(base, [4]) + (base == [1, 2, 3]) && (extended == [1, 2, 3, 4]) +} + +fn map_prior_version_survives_insert_holds() -> Bool { + let base = empty_map() |> map_insert("k", 1) + let updated = base |> map_insert("k", 2) + let base_intact = base == (empty_map() |> map_insert("k", 1)) + let update_took = updated == (empty_map() |> map_insert("k", 2)) + base_intact && update_took && !(updated == base) +} + // ── Glob-discovery registrations (consolidation #4553 Phase 2) ──────────── // Co-located BoolWitnessClaim markers so discovery finds each witness // without a central roster. @@ -173,3 +196,17 @@ data unified_claim_name_interning_covers_all_domains: UnifiedTestClaim = BoolWit function: name_interning_covers_all_domains_holds } } + +data unified_claim_list_prior_version_survives_concat: UnifiedTestClaim = BoolWitnessClaim { + witness: BoolWitness { + entry: "src/v4/test/claim/std_grounding/value_carrier_laws.dag", + function: list_prior_version_survives_concat_holds + } +} + +data unified_claim_map_prior_version_survives_insert: UnifiedTestClaim = BoolWitnessClaim { + witness: BoolWitness { + entry: "src/v4/test/claim/std_grounding/value_carrier_laws.dag", + function: map_prior_version_survives_insert_holds + } +}