b724cd17a1
M5 iteration 2 (specae905de, plan9cc9d9c). Tasks 1+2 clean and committable; Task 3 BLOCKED on a real finding → M5 bounce-back. Shipped: - data-server promoted [dev-dependencies] -> [dependencies] (Invariant-1 sanctioned: ail-embed is the sole meeting point; the AILang workspace graph still has data-server count 0; zero compiler-surface diff — not even root Cargo.toml this iter). - additive `adapter` module (ail-embed/src/adapter.rs): tick_to_px, MidPriceStream (lazy Iterator<f64> over SymbolChunkIter), fold_symbol; RED-first (E0432 -> GREEN). m5.1 core untouched except `pub mod adapter;`. - swarm_runner [[bin]]: one thread per symbol, each owning its Kernel. The clean compile IS the compile-time per-thread-ctx proof (Ctx: !Send => a shared-ctx swarm is E0277). - tests/swarm.rs: real-data symbol-fan E2E. `symbol_fan_swarm_bit_exact` is GREEN and live — per-symbol kernel (acc,n) bit-exact vs an independent same-order host reference (EURUSD/GER40/XAUUSD, ~4s). The Task 3 finding (Boss independently confirmed by reading runtime/rc.c): the global Σallocs==Σfrees leak measurement is an INSTRUMENTATION race, not a memory bug. No box crosses a thread (Ctx: !Send), the real refcount/free is correct (bit-exact GREEN every run); only the global g_rc_* stat counters (rc.c:90-91, 161,212) are non-atomic BY rc.c's own documented single-threaded design, losing ++s when 3 worker threads hit the null-__ail_tls_ctx host-side path. M5 is AILang's first concurrent consumer; rc.c's header (rc.c:44-49) explicitly defers exactly this atomic-vs-non- atomic decision to "when it acquires concurrency primitives". The leak assertion was Boss-split into `symbol_fan_swarm_leak_free` (#[ignore], body preserved VERBATIM — quarantined not weakened; un-ignore = the runtime fix's acceptance criterion) so main stays green and the finding is pinned as a regression marker. The earlier plan/journal claim that embed_swarm_tsan.rs covers this path was wrong and is corrected on the record (that test uses the scalar kernel — zero box allocs — so it never exercised the host-side global-counter path). Escalated as an M5 bounce-back: resolution touches M3-frozen runtime/ and re-frames M5's "zero runtime change" commitment; multiple substantive options, not unilaterally Boss's to pick in frozen-runtime territory. Boss recommendation on the record = Option A (make only the global-fallback g_rc_* counters atomic, as a standalone RED-first runtime micro-iteration; M5 framing amended). Time-shard + friction-harvest remain m5.3. Includes the per-iter journal (with Boss disposition), stats, and the INDEX.md line.
165 lines
7.0 KiB
Rust
165 lines
7.0 KiB
Rust
//! Symbol-fan swarm E2E (spec Testing §2). Runs the `swarm_runner`
|
|
//! bin as a subprocess under `AILANG_RC_STATS=1` (the C-runtime
|
|
//! atexit stat line, runtime/rc.c:124-138, is only observable from a
|
|
//! separate process that exits). Skips when /mnt tick data is absent
|
|
//! — mirroring data-server's own tests/data_server.rs::skip_if_no_data().
|
|
//!
|
|
//! Two assertions, deliberately SPLIT into two tests:
|
|
//!
|
|
//! - `symbol_fan_swarm_bit_exact` — the existence proof. Per symbol,
|
|
//! the kernel `(acc,n)` is BIT-EXACT vs an independent same-order
|
|
//! host reference fold. This is GREEN and live: it proves the
|
|
//! real-data-server → adapter → M3-kernel swarm computes correctly.
|
|
//!
|
|
//! - `symbol_fan_swarm_leak_free` — `#[ignore]`d pending an escalated
|
|
//! design decision. The global `Σallocs==Σfrees` measurement
|
|
//! (ported from crates/ail/tests/embed_tick_e2e.rs:94-104) is
|
|
//! *invalid under a multi-threaded host*: host-side box allocs run
|
|
//! with `__ail_tls_ctx == NULL` and fall through to the
|
|
//! **non-atomic** global `g_rc_*` counters (runtime/rc.c:90-91,
|
|
//! 161,212), which rc.c's own header explicitly documents as
|
|
//! single-threaded/non-atomic ("when it acquires [concurrency],
|
|
//! atomic-vs-non-atomic becomes a separate decision"). The swarm is
|
|
//! *actually* leak-free (no box crosses a thread — `Ctx: !Send`;
|
|
//! bit-exact is GREEN every run); only the global *counter* races,
|
|
//! losing increments. The assertion body is preserved verbatim and
|
|
//! will be re-armed (un-`#[ignore]`d) by the runtime iteration that
|
|
//! makes the global fallback counters atomic. See
|
|
//! docs/journals/2026-05-19-iter-embedding-abi-m5.2.md and the M5
|
|
//! bounce-back. Quarantined, NOT weakened — what it asserts is
|
|
//! unchanged; it is gated on a decision the Boss escalated.
|
|
|
|
use std::process::Command;
|
|
use std::sync::Arc;
|
|
|
|
use data_server::records::DataFormat;
|
|
use data_server::{DataServer, DEFAULT_DATA_PATH};
|
|
|
|
// Must match swarm_runner.rs.
|
|
const MAX_TICKS: usize = 2_000_000;
|
|
const N_SYMBOLS: usize = 4;
|
|
|
|
fn skip_if_no_data() -> bool {
|
|
!std::path::Path::new(DEFAULT_DATA_PATH).exists()
|
|
}
|
|
|
|
/// Independent host reference: same symbols, same order, same cap,
|
|
/// pure-Rust fold (`acc += mid; n += 1`). Bit-exact with the kernel
|
|
/// fold because the addition order is identical.
|
|
fn reference(symbol: &str) -> (f64, i64) {
|
|
let server = Arc::new(DataServer::new(DEFAULT_DATA_PATH));
|
|
let mut it = server.stream_tick(symbol).expect("tick stream");
|
|
let (mut acc, mut n) = (0.0_f64, 0_i64);
|
|
'outer: while let Some(chunk) = it.next_chunk() {
|
|
for r in chunk.iter() {
|
|
if n as usize >= MAX_TICKS {
|
|
break 'outer;
|
|
}
|
|
acc += (r.ask + r.bid) / 2.0;
|
|
n += 1;
|
|
}
|
|
}
|
|
(acc, n)
|
|
}
|
|
|
|
/// Spawn `swarm_runner` under `AILANG_RC_STATS=1`. `None` on the
|
|
/// skip-if-absent path (no /mnt data); otherwise `(stdout, stderr)`
|
|
/// after asserting a clean child exit.
|
|
fn run_swarm_or_skip() -> Option<(String, String)> {
|
|
if skip_if_no_data() {
|
|
eprintln!("skipping: {DEFAULT_DATA_PATH} absent (mirrors data-server)");
|
|
return None;
|
|
}
|
|
let out = Command::new(env!("CARGO_BIN_EXE_swarm_runner"))
|
|
.arg(DEFAULT_DATA_PATH)
|
|
.env("AILANG_RC_STATS", "1")
|
|
.output()
|
|
.expect("spawn swarm_runner");
|
|
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
|
|
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
|
|
assert!(
|
|
out.status.success(),
|
|
"swarm_runner exited {:?}\nstdout:\n{stdout}\nstderr:\n{stderr}",
|
|
out.status.code()
|
|
);
|
|
Some((stdout, stderr))
|
|
}
|
|
|
|
/// Existence proof (GREEN, live): each symbol's kernel `(acc,n)` is
|
|
/// bit-exact vs an independent same-order host reference fold. Real
|
|
/// data-server → adapter → M3-frozen kernel, one symbol per thread.
|
|
#[test]
|
|
fn symbol_fan_swarm_bit_exact() {
|
|
let Some((stdout, _stderr)) = run_swarm_or_skip() else { return };
|
|
|
|
let mut symbols_checked = 0usize;
|
|
for line in stdout.lines().filter(|l| l.starts_with("RESULT ")) {
|
|
let mut t = line.split_whitespace();
|
|
let _ = t.next(); // "RESULT"
|
|
let sym = t.next().expect("symbol field");
|
|
let acc_bits = u64::from_str_radix(
|
|
t.next().expect("acc-bits field"), 16,
|
|
).expect("acc-bits hex");
|
|
let n: i64 = t.next().expect("n field").parse().expect("n int");
|
|
|
|
let kernel_acc = f64::from_bits(acc_bits);
|
|
let (ref_acc, ref_n) = reference(sym);
|
|
assert_eq!(
|
|
kernel_acc.to_bits(), ref_acc.to_bits(),
|
|
"{sym}: kernel acc bit-exact vs same-order host reference"
|
|
);
|
|
assert_eq!(n, ref_n, "{sym}: tick count matches reference");
|
|
assert!(ref_n > 0, "{sym}: non-empty stream");
|
|
symbols_checked += 1;
|
|
}
|
|
assert!(
|
|
(2..=N_SYMBOLS).contains(&symbols_checked),
|
|
"expected 2..={N_SYMBOLS} symbols fanned, got {symbols_checked}"
|
|
);
|
|
}
|
|
|
|
/// Global leak-freedom: Σallocs == Σfrees across ALL `ailang_rc_stats:`
|
|
/// lines (the M2 dual-stat-line model, ported from
|
|
/// embed_tick_e2e.rs:94-104). The invariant is the global Σ, not
|
|
/// per-line balance (M2 TLS-ctx cross-attribution).
|
|
///
|
|
/// `#[ignore]`d: this measurement is invalid under a multi-threaded
|
|
/// host. Host-side box allocs run with `__ail_tls_ctx == NULL` and
|
|
/// hit the non-atomic global `g_rc_*` counters (runtime/rc.c:90-91,
|
|
/// 161,212) — single-threaded/non-atomic *by rc.c's own documented
|
|
/// design*. 3 worker threads race the counter, losing increments;
|
|
/// the *counter* is unreliable even though the swarm is actually
|
|
/// leak-free (no box crosses a thread; `symbol_fan_swarm_bit_exact`
|
|
/// is GREEN every run). Un-`#[ignore]` this — body unchanged — once
|
|
/// the runtime iteration makes the global fallback counters atomic
|
|
/// (the deferred "concurrency arrived" decision rc.c:44-49 names).
|
|
/// See docs/journals/2026-05-19-iter-embedding-abi-m5.2.md.
|
|
#[test]
|
|
#[ignore = "blocked: global g_rc_* counter is non-atomic by rc.c design; \
|
|
invalid under the multi-thread host. Swarm is actually \
|
|
leak-free (bit-exact is green; no box crosses a thread). \
|
|
Un-ignore when the runtime makes the global fallback \
|
|
counters atomic — see the m5.2 journal + M5 bounce-back."]
|
|
fn symbol_fan_swarm_leak_free() {
|
|
let Some((_stdout, stderr)) = run_swarm_or_skip() else { return };
|
|
|
|
let mut allocs: u64 = 0;
|
|
let mut frees: u64 = 0;
|
|
let mut seen = 0usize;
|
|
for line in stderr.lines().filter(|l| l.starts_with("ailang_rc_stats:")) {
|
|
seen += 1;
|
|
for tok in line.split_whitespace() {
|
|
if let Some(v) = tok.strip_prefix("allocs=") {
|
|
allocs += v.parse::<u64>().expect("allocs= u64");
|
|
} else if let Some(v) = tok.strip_prefix("frees=") {
|
|
frees += v.parse::<u64>().expect("frees= u64");
|
|
}
|
|
}
|
|
}
|
|
assert!(seen > 0, "no ailang_rc_stats line; stderr:\n{stderr}");
|
|
assert_eq!(
|
|
allocs, frees,
|
|
"globally leak-free: Σallocs==Σfrees across {seen} stat line(s)"
|
|
);
|
|
}
|