diff --git a/crates/ogar-loco/examples/funnel_probe.rs b/crates/ogar-loco/examples/funnel_probe.rs new file mode 100644 index 0000000..ae8c65d --- /dev/null +++ b/crates/ogar-loco/examples/funnel_probe.rs @@ -0,0 +1,193 @@ +//! PROBE-ORACLE-FUNNEL Stage 0 — does the funnel discriminate? +//! +//! Pre-registration: lance-graph `.claude/plans/oracle-funnel-probe-v1.md` +//! (expectations E1–E4 written before this ran). Three deterministic +//! generator arms bracket what an LLM could emit; the shipped gates +//! (`validate` → `statement_bounds`) refuse or pass; `FunnelTally` (W-4) +//! reports which gate fired. No LLM, no API, no new mints — the empty-domain +//! blockly shared core only. +//! +//! Honest boundary: bodies are ≤16 calls under `LaneShape::Pairs` with +//! single-byte immediates, so the body-ENTRY gate (`BodyError`) is +//! structurally excluded here — Stage 0 exercises the segmentation gate. +//! +//! Run: `cargo run -p ogar-loco --example funnel_probe` + +use ogar_loco::statements::{StatementError, statement_bounds}; +use ogar_loco::telemetry::{FunnelTally, RefusalGate}; +use ogar_loco::vocabulary::conformance::validate; +use ogar_loco::{Call, FnIndex, FunctionBody, LaneShape, Vocabulary}; + +const N: usize = 1000; +const MAX_LEN: u64 = 16; +const SEED: u64 = 0x9E37_79B9_7F4A_7C15; + +/// SplitMix64 — the workspace's deterministic sampler (certification-officer +/// convention). Zero-dep, bit-exact across runs. +struct SplitMix64(u64); + +impl SplitMix64 { + fn next(&mut self) -> u64 { + self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = self.0; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + } + + /// Uniform in `0..bound` (modulo bias negligible at these bounds vs u64). + fn below(&mut self, bound: u64) -> u64 { + self.next() % bound + } +} + +struct EmptyVocab; +impl Vocabulary for EmptyVocab { + fn domain_stack_arity(&self, _f: FnIndex) -> Option { + None + } + fn domain_body_refs(&self, _f: FnIndex) -> u8 { + 0 + } +} + +fn main() { + let checked = validate(EmptyVocab).expect("blockly shared core validates"); + let table = checked.table(); + + // The segmentable set: slots declaring BOTH arity and pushes_result. + let segmentable: Vec = (0..=u8::MAX) + .map(FnIndex) + .filter(|f| table.stack_arity(*f).is_some() && table.pushes_result(*f).is_some()) + .collect(); + println!("segmentable slots: {}", segmentable.len()); + + // ── Legend (the Stage-1 prompt anchor): named+covered slots only ── + let mut legend = String::new(); + for f in (0..=u8::MAX).map(FnIndex) { + if let (Some(name), Some(arity), Some(pushes)) = ( + table.spec(f).name, + table.stack_arity(f), + table.pushes_result(f), + ) { + legend.push_str(&format!( + "0x{:02X} {name} arity={arity} pushes={pushes}\n", + f.0 + )); + } + } + let lines = legend.lines().count(); + println!( + "legend: {} lines, {} bytes, ~{} tokens (bytes/4 estimate)\n", + lines, + legend.len(), + legend.len() / 4 + ); + + type Gen = fn(&mut SplitMix64, &[FnIndex], &ogar_loco::VocabularyTable) -> Vec; + let arms: [(&str, Gen); 3] = [ + ("R (floor: uniform bytes)", gen_uniform), + ("G (mid: legend-constrained)", gen_legend), + ("W (ceiling: stack-aware walk)", gen_walk), + ]; + + for (label, generate) in arms { + let mut rng = SplitMix64(SEED); + let mut tally = FunnelTally::default(); + let mut stmt_counts: Vec = Vec::new(); + for _ in 0..N { + let calls = generate(&mut rng, &segmentable, table); + let body = FunctionBody::from_calls(LaneShape::Pairs, &calls) + .expect("constructions stay inside the Pairs budget"); + match statement_bounds(&checked, &body) { + Ok(stmts) => { + tally.record(Ok(())); + stmt_counts.push(stmts.len()); + } + Err(e) => tally.record(Err(RefusalGate::from(&e))), + } + } + println!("arm {label}: {}/{} survived", tally.survived, tally.total()); + for (gate, n) in tally.nonzero_gates() { + println!(" {gate:?}: {n}"); + } + if !stmt_counts.is_empty() { + let sum: usize = stmt_counts.iter().sum(); + let max = stmt_counts.iter().max().copied().unwrap_or(0); + println!( + " survivor statements: mean {:.2}, max {max}", + sum as f64 / stmt_counts.len() as f64 + ); + } + println!(); + } + + // Two-sided check on the mapping itself: one known-refused body per gate + // must land on the RIGHT gate (the funnel's own can-it-fire test). + let checked2 = validate(EmptyVocab).unwrap(); + let underflow = FunctionBody::from_calls(LaneShape::Pairs, &[Call::new(FnIndex::ADD)]).unwrap(); + assert!(matches!( + statement_bounds(&checked2, &underflow), + Err(StatementError::StackUnderflow { .. }) + )); + println!("gate-identity spot check: StackUnderflow lands on StackUnderflow — ok"); +} + +/// Arm R: every byte uniform — models an LLM that knows nothing. +fn gen_uniform( + rng: &mut SplitMix64, + _seg: &[FnIndex], + _table: &ogar_loco::VocabularyTable, +) -> Vec { + let len = 1 + rng.below(MAX_LEN) as usize; + (0..len) + .map(|_| Call::with_value(FnIndex(rng.below(256) as u8), rng.below(256) as u8)) + .collect() +} + +/// Arm G: calls uniform over the segmentable set — read the legend, not the +/// grammar. +fn gen_legend( + rng: &mut SplitMix64, + seg: &[FnIndex], + _table: &ogar_loco::VocabularyTable, +) -> Vec { + let len = 1 + rng.below(MAX_LEN) as usize; + (0..len) + .map(|_| { + let f = seg[rng.below(seg.len() as u64) as usize]; + Call::with_value(f, rng.below(256) as u8) + }) + .collect() +} + +/// Arm W: stack-aware — only calls whose arity fits the current depth; stops +/// at a statement boundary (depth 0) or a single trailing value (depth 1). +fn gen_walk( + rng: &mut SplitMix64, + seg: &[FnIndex], + table: &ogar_loco::VocabularyTable, +) -> Vec { + let target = 1 + rng.below(MAX_LEN) as usize; + let mut calls = Vec::new(); + let mut depth = 0usize; + while calls.len() < target || depth > 1 { + let eligible: Vec = seg + .iter() + .copied() + .filter(|f| usize::from(table.stack_arity(*f).unwrap()) <= depth) + .collect(); + let f = eligible[rng.below(eligible.len() as u64) as usize]; + depth -= usize::from(table.stack_arity(f).unwrap()); + if table.pushes_result(f).unwrap() { + depth += 1; + } + calls.push(Call::with_value(f, rng.below(256) as u8)); + // Safety valve so a run of pushes can always be consumed within a + // bounded body: past the target, prefer any depth-reducing step. + if calls.len() > target + 32 { + break; + } + } + calls +} diff --git a/docs/BLOCK-EDITOR-PLAN.md b/docs/BLOCK-EDITOR-PLAN.md index ab374ca..bfe55ce 100644 --- a/docs/BLOCK-EDITOR-PLAN.md +++ b/docs/BLOCK-EDITOR-PLAN.md @@ -721,3 +721,34 @@ All five landed; nothing on #241's gated list was touched. Open questions the handover left, both resolved here: **OQ-1** (name placement) → in `FnSpec`. **OQ-2** (144 shared slots = 144 rung-2 atoms) → left as recorded numerology; not addressed, not ruled on. + +## PROBE-ORACLE-FUNNEL Stage 0 — the funnel discriminates (measured, 2026-08-05) + +Harness: `crates/ogar-loco/examples/funnel_probe.rs` (deterministic, SplitMix64 +seed `0x9E3779B97F4A7C15`, N=1000/arm, Pairs, len 1..=16). Pre-registration +with expectations E1–E4 written BEFORE the run lives consumer-side (lance-graph +`.claude/plans/oracle-funnel-probe-v1.md`); the probe consumes only shipped +gates — `validate` (W-1 path), `statement_bounds` (R5), `FunnelTally` (W-4), +the empty-domain blockly core. No LLM, no API, no mints. + +Measured (all four pre-registered expectations met): + +| arm | model of the generator | survived | dominant refusal | +|---|---|---|---| +| R (floor) | uniform random bytes | 5/1000 (0.5%) | `StatementUncovered` 831 | +| G (mid) | knows the legend, not the grammar | 25/1000 (2.5%) | `StatementUnderflow` 970 | +| W (ceiling) | stack-aware walk | 1000/1000 (100%) | — (stmts mean 2.32, max 8) | + +- **E4 (the KILL) passes wide:** R↔W spread 99.5 points ≥ 50 — the + refuse-don't-guess firewall genuinely discriminates; an LLM arm (Stage 1, + consumer-side, gated) is justified on this vocabulary. +- **The extra finding: the legend is not the grammar.** Arm G sits at the + floor (2.5% vs 0.5%), not midway — vocabulary knowledge buys almost + nothing; ~all constraint lives in the stack discipline. A Stage-1 prompt + must teach the postfix discipline, not merely list the table. +- **Legend cost anchor:** 50 named+covered slots → 1531 bytes ≈ 382 tokens + (bytes/4 estimate) — negligible prompt overhead. +- **Honest boundary:** bodies ≤16 calls / Pairs / single-byte immediates, so + `BodyError` (entry gate) is structurally unexercised; Stage 0 measures the + segmentation gate. `FunnelTally`'s gate-identity mapping spot-checked + (underflow lands on `StatementUnderflow`).