Skip to content

shenffi/CasEngine: nondeterministic evaluation of shaken shen-cas slice (wrong answers + in-session perf degradation) #8

Description

@pyrex41

Summary

Running the tree-shaken shen-cas slice through shenffi::CasEngine, evaluation is nondeterministic relative to the reference Shen kernel. The same input yields one of three outcomes — correct, unreduced (inert leftover), or silently wrong — selected by process/boot state. The same machinery also degrades in performance the longer a session runs. The reference evaluator (the identical CAS program under ShenScript) is correct and deterministic, so the divergence is introduced by shen-rust's execution, not by the shen-cas rules.

This is the root cause of the now-closed shen-cas issues pyrex41/shen-cas#5 (wrong/unreduced D[ArcTan[x],x]) and pyrex41/shen-cas#6 (Integrate latency), both retargeted here.

Decisive repro — two fresh boots in one process disagree

crates/shenffi/examples/cas_bench.rs (run on a large-stack thread, as the README requires):

use shenffi::CasEngine;
fn main() {
    std::thread::Builder::new().stack_size(512*1024*1024)
        .spawn(|| {
            let mut e1 = CasEngine::boot().unwrap();
            println!("e1 D[x,x]         = {}", e1.reduce("D[x,x]"));
            println!("e1 D[ArcTan[x],x] = {}", e1.reduce("D[ArcTan[x],x]"));
            let mut e2 = CasEngine::boot().unwrap();
            println!("e2 D[x,x]         = {}", e2.reduce("D[x,x]"));
            println!("e2 D[ArcTan[x],x] = {}", e2.reduce("D[ArcTan[x],x]"));
        }).unwrap().join().unwrap();
}

Observed:

e1 D[x,x]         = [D x x]                                  <- inert (rule didn't fire)
e1 D[ArcTan[x],x] = [Times [D x x] [Power [Plus [Power x 2] 1] -1]]   <- unreduced
e2 D[x,x]         = 1
e2 D[ArcTan[x],x] = 1                                        <- WRONG (correct: (1+x^2)^-1)
engine 1 (1st boot) engine 2 (2nd boot) reference (ShenScript)
D[x,x] [D x x] (inert) 1 1
D[ArcTan[x],x] [Times [D x x] [Power…-1]] 1 (wrong) (1+x^2)^-1

Each CasEngine is freshly booted (own Interp), so it is not memo-priming/history. Both share the process, and 8 separate process launches all agreed — so it is not a per-process RandomState seed either. The only discriminating variable is boot order within the process, i.e. process-global state that survives across Interp::new() / boot_from_kl_source.

Why this points at intern/hash state

The guarded calculus rules both modes hinge on (D[x,x] → 1 guarded by SameQ, D[c,x] → 0 by FreeQ) evaluate through the CAS's content-eq → content-hash, and the CAS's normal-form memo is content-hash-keyed. shen-cas deliberately uses a self-contained polynomial rolling hash (base 31, large-prime modulus) precisely because host hash is unsafe across ports (see shen-cas HANDOFF.md "Portable content hash"). If shen-rust computes that rolling hash with fixed-width integer wraparound (vs ShenScript's number type) the modular arithmetic diverges → collisions; and if symbol interning / a global symbol table is not reset per boot, the collision set shifts by boot order — exactly the observed behavior.

Second symptom — monotonic in-session perf degradation

The identical Integrate[x^4,x] call, repeated 8× in one process:

#0 4.08s  #1 5.08s  #2 5.23s  #3 16.28s  #4 14.81s  #5 14.49s  #6 13.64s  #7 14.00s

Same input, same process, climbing 4s → 16s and plateauing — a state-accumulation cost (content-hash-keyed nf-memo / store growth), the same machinery as above. The reference (ShenScript, a slower interpreter) does these in <1.2s, so this is a native-vs-interpreter performance inversion. On the app's single long-lived worker thread this reads as a progressive slowdown / "hang."

Suggested investigation

  1. Is shen-rust's number/integer arithmetic for the CAS's rolling hash arbitrary-precision, or does it wrap at i64? Wraparound would produce host-specific collisions.
  2. Is the symbol intern table (and any global hash/symbol state) reset on each Interp::new() / boot_from_kl_source? The two-boot divergence says some global state leaks.
  3. Profile the nf-memo / store under repeated reduction in one boot (the 4s→16s climb) — is content-hash recomputed over growing state, or are map lookups degrading?

Expected

CasEngine::reduce should match the reference byte-for-byte and be boot-order- and history-independent: D[x,x] → 1, D[ArcTan[x],x] → (1+x^2)^-1, every boot, every call.

Provenance

Found while evaluating shen-cas #5/#6 against the shipped slice; cross-checked against the pure-Shen reference via shen-cas/scripts/shenscript-run.js.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions