From 3a50c1882d3b508c2781f94a64d8c696135c42ce Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 14:13:58 +0000 Subject: [PATCH] ogar-loco: consumer wishlist W-1/W-2/W-4/W-5 from the lance-graph/rig handover Implements the source-verified consumer-priority wishlist in .claude/handovers/2026-08-05-1430-lance-graph-orchestrator-to-ogar-loco.md (#243), written from the seat that will consume the loco ABI: lance-graph's rig/rs-graph-llm oracle loop and the compiled-template stack. W-3 (the NARS-34 mint) stays deferred, exactly as the handover asked -- ids are the operator's. W-1 closes finding F-1 (compose-then-check hardening): validate() now composes the table FIRST and re-checks the shape invariant on the STORED table, not only through the method sweep. The prior check-then-compose order sampled the domain hooks twice, so a phase-unstable vocabulary could pass check() on one set of answers while compose() froze a different one into the table the wrapper carries -- including a body_refs past a call's capacity, reopening the traversal edge the CheckedVocabulary proof exists to close. Two-directional can-fire test: poisoning EITHER sampling (the table's or the method sweep's) is caught, whichever gate sees it; a stable vocabulary still validates clean. W-2 adds the name column: shared_core::name + Vocabulary::name / domain_name, landing IN FnSpec per OQ-1 (the table stays the single artifact -- a legend becomes a serialization of the validated table, not a second lookup surface that can drift from it). Names are NOT coverage -- WAIT/STOP/etc are named so a legend can say "exists, refused" instead of omitting them into apparent nonexistence. A fourth drift channel, guarded exactly like stack_arity/body_refs/pushes_result (can-fire test: a vocabulary renaming ADD is caught by name). W-4 adds telemetry::FunnelTally / RefusalGate: a plain data tally over a generate-and-filter batch (ConformanceError / StatementError / PoolError -> gate -> count). Deliberately validity-feedback-only -- no scoring, no ranking, no fitness scalar; that boundary is lance-graph's observer-effect payload law, out of scope here by design. W-5 is doc-only, in statements.rs: the >64-statement split contract. Split unit is a sibling function (StepMask stays u64 forever, never widened); statement ordinals restart at 0 per split function; the cut falls on a statement_bounds boundary, never mid-statement; which call enters the sibling is the vocabulary's decision, this crate mints none. Also records both open questions the handover left (OQ-1 resolved in FnSpec; OQ-2 left as numerology, not ruled on) in the plan ledger. Gates: fmt, clippy -D warnings, tests (51 loco + 7 blockly), rustdoc -D warnings -- all green. --- crates/ogar-loco/src/lib.rs | 2 + crates/ogar-loco/src/statements.rs | 38 ++++ crates/ogar-loco/src/telemetry.rs | 242 +++++++++++++++++++++++ crates/ogar-loco/src/vocabulary.rs | 295 ++++++++++++++++++++++++++++- docs/BLOCK-EDITOR-PLAN.md | 41 ++++ 5 files changed, 614 insertions(+), 4 deletions(-) create mode 100644 crates/ogar-loco/src/telemetry.rs diff --git a/crates/ogar-loco/src/lib.rs b/crates/ogar-loco/src/lib.rs index 8cd920d..596e28e 100644 --- a/crates/ogar-loco/src/lib.rs +++ b/crates/ogar-loco/src/lib.rs @@ -123,12 +123,14 @@ pub mod node; pub mod pool; pub mod program; pub mod statements; +pub mod telemetry; pub mod vocabulary; pub use node::FunctionNode; pub use pool::{Constant, ConstantPool, PoolError}; pub use program::{Program, branches_of}; pub use statements::{StatementBounds, StatementError, statement_bounds}; +pub use telemetry::{FunnelTally, RefusalGate}; pub use vocabulary::conformance::CheckedVocabulary; pub use vocabulary::{FnSpec, Vocabulary, VocabularyTable}; diff --git a/crates/ogar-loco/src/statements.rs b/crates/ogar-loco/src/statements.rs index 428710b..8d5322d 100644 --- a/crates/ogar-loco/src/statements.rs +++ b/crates/ogar-loco/src/statements.rs @@ -15,6 +15,44 @@ //! A template wanting more than 64 statements is a split signal, not a //! mask-widening use case. //! +//! # The >64-statement split contract (wishlist W-5) +//! +//! `statement_bounds` reports the count; it has no opinion on what a +//! lowerer does past 64, because that choice belongs to the vocabulary, not +//! the core. What IS fixed, so N lowerers do not converge on N divergent +//! conventions: +//! +//! - **The split unit is a function, never the mask.** `StepMask` stays a +//! `u64` forever (see `lance_graph_contract::step_mask`); it is never +//! widened to reach a 65th statement. A body over 64 statements is lowered +//! as **two (or more) sibling function bodies**, referenced the same way +//! any nested body is referenced — by index, in the value bytes of a +//! dispatching call in the FIRST body. This is literally the same +//! overflow remedy the ABI uses everywhere else (`BodyError::Overflow`, +//! `PoolError::Full`): split, never widen. +//! - **Statement ordinals restart at 0 in each split function.** A `StepMask` +//! is scoped to the function it selects over, exactly like it already is +//! for a single body — a global statement numbering across the split +//! would smuggle a second addressing scheme past the classid. +//! - **The split point falls on a statement boundary, never mid-statement.** +//! `statement_bounds` already gives the lowerer exactly the boundaries +//! `[first_call, call_count]` a split must respect — cutting inside one +//! would separate an operand run from its consumer, the same +//! desynchronization masking a raw call would cause. +//! - **Whether the sibling body is entered by an unconditional dispatch +//! call (a `CONTINUATION`-shaped hop, always taken) or the vocabulary's +//! own control flow (e.g. a template's own sequencing verb) is a +//! vocabulary decision** — this crate does not mint that call. What is +//! fixed is only the shape (function split, statement-aligned, forward +//! reference) so every vocabulary's split is interoperable at the level a +//! generic tool (a renderer, a step-mask dispatcher) needs to reason +//! about it. +//! +//! A 65-statement body is therefore never a hard error at this layer — it +//! is a signal a vocabulary-side lowering pass must act on, the same way +//! `BodyError::Overflow` is a signal `Program`'s caller must act on rather +//! than something this crate resolves for it. +//! //! # The segmentation rule //! //! Walk the calls simulating stack depth (`depth -= arity; depth += 1` if diff --git a/crates/ogar-loco/src/telemetry.rs b/crates/ogar-loco/src/telemetry.rs new file mode 100644 index 0000000..d2289bf --- /dev/null +++ b/crates/ogar-loco/src/telemetry.rs @@ -0,0 +1,242 @@ +//! Funnel telemetry — refusal statistics as data (wishlist W-4). +//! +//! # The oracle contract's safe half +//! +//! A generate-and-filter loop (an LLM emitting N candidate bodies, this +//! crate's parse/validate/cast refusing the garbage) needs to know WHICH +//! gate killed each surviving-or-not candidate, so the loop can react — +//! re-prompt, narrow the vocabulary shown, adjust the shape. That is +//! **validity feedback**: "candidate 7 underflowed the stack at call 3." It +//! is safe to hand back to a generator raw; it names a structural defect in +//! the emitted bytes, not a judgment about the candidate's quality. +//! +//! **Fitness feedback — how WELL a surviving candidate performed — is +//! deliberately out of scope here.** That is a downstream instrument's +//! concern (lance-graph's observer-effect payload law: distribution shape × +//! rank, never a raw scalar looped back into the generator), and this crate +//! has no fitness signal to report in the first place — it only knows +//! whether a candidate parses, casts, and segments. +//! +//! This module is therefore a **pure tally**: fold a batch of +//! [`Result`]s from the crate's own error types into counts per variant. +//! No scoring, no ranking, no scalar feedback — the safe slice, and +//! nothing past it. + +use crate::pool::PoolError; +use crate::statements::StatementError; +use crate::vocabulary::conformance::ConformanceError; + +/// Which named gate refused a candidate — a flat key so counts can be +/// reported without matching on three different error enums downstream. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[non_exhaustive] +pub enum RefusalGate { + /// [`ConformanceError::SharedCoreDrift`]. + ConformanceDrift, + /// [`ConformanceError::ShapeTooNarrowForRefs`]. + ConformanceShapeTooNarrow, + /// [`StatementError::Uncovered`]. + StatementUncovered, + /// [`StatementError::StackUnderflow`]. + StatementUnderflow, + /// [`StatementError::DanglingOperands`]. + StatementDangling, + /// [`PoolError::Full`]. + PoolFull, + /// [`PoolError::TooWide`]. + PoolTooWide, +} + +impl RefusalGate { + /// Every gate, for a stable iteration/report order. + pub const ALL: [RefusalGate; 7] = [ + RefusalGate::ConformanceDrift, + RefusalGate::ConformanceShapeTooNarrow, + RefusalGate::StatementUncovered, + RefusalGate::StatementUnderflow, + RefusalGate::StatementDangling, + RefusalGate::PoolFull, + RefusalGate::PoolTooWide, + ]; +} + +impl From<&ConformanceError> for RefusalGate { + fn from(e: &ConformanceError) -> Self { + match e { + ConformanceError::SharedCoreDrift { .. } => RefusalGate::ConformanceDrift, + ConformanceError::ShapeTooNarrowForRefs { .. } => { + RefusalGate::ConformanceShapeTooNarrow + } + } + } +} + +impl From<&StatementError> for RefusalGate { + fn from(e: &StatementError) -> Self { + match e { + StatementError::Uncovered { .. } => RefusalGate::StatementUncovered, + StatementError::StackUnderflow { .. } => RefusalGate::StatementUnderflow, + StatementError::DanglingOperands { .. } => RefusalGate::StatementDangling, + } + } +} + +impl From<&PoolError> for RefusalGate { + fn from(e: &PoolError) -> Self { + match e { + PoolError::Full => RefusalGate::PoolFull, + PoolError::TooWide { .. } => RefusalGate::PoolTooWide, + } + } +} + +/// A batch's refusal tally — how many candidates survived, and how many the +/// funnel refused, broken down by [`RefusalGate`]. +/// +/// Deliberately data-only: no ranking, no scoring, no fitness signal. Build +/// with [`FunnelTally::default`] and [`record`](Self::record) each +/// candidate's outcome as the batch runs, or fold a slice of results with +/// [`FunnelTally::from_results`]. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct FunnelTally { + /// How many candidates survived every gate. + pub survived: u32, + /// Refusal counts, one entry per [`RefusalGate::ALL`] member in order. + counts: [u32; RefusalGate::ALL.len()], +} + +impl FunnelTally { + /// A tally over an already-collected batch of outcomes. + #[must_use] + pub fn from_results<'a, E, I>(results: I) -> Self + where + E: 'a, + RefusalGate: for<'b> From<&'b E>, + I: IntoIterator>, + { + let mut t = Self::default(); + for r in results { + t.record(r.as_ref().map(|_| ()).map_err(RefusalGate::from)); + } + t + } + + /// Record one candidate's outcome: `Ok(())` for a survivor, `Err(gate)` + /// for a refusal at the named gate. + pub fn record(&mut self, outcome: Result<(), RefusalGate>) { + match outcome { + Ok(()) => self.survived += 1, + Err(gate) => { + let i = RefusalGate::ALL + .iter() + .position(|g| *g == gate) + .expect("RefusalGate::ALL is exhaustive over the enum"); + self.counts[i] += 1; + } + } + } + + /// How many candidates this tally has seen in total. + #[must_use] + pub fn total(&self) -> u32 { + self.survived + self.counts.iter().sum::() + } + + /// Refusals at one gate. + #[must_use] + pub fn at(&self, gate: RefusalGate) -> u32 { + let i = RefusalGate::ALL + .iter() + .position(|g| *g == gate) + .expect("RefusalGate::ALL is exhaustive over the enum"); + self.counts[i] + } + + /// `(gate, count)` for every gate that refused at least one candidate, + /// in [`RefusalGate::ALL`] order — the report a caller actually wants + /// (a batch that never hit `PoolFull` should not print a zero row). + pub fn nonzero_gates(&self) -> impl Iterator + '_ { + RefusalGate::ALL + .into_iter() + .zip(self.counts) + .filter(|(_, n)| *n > 0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_mixed_batch_tallies_survivors_and_gates_separately() { + let mut t = FunnelTally::default(); + t.record(Ok(())); + t.record(Ok(())); + t.record(Err(RefusalGate::StatementUnderflow)); + t.record(Err(RefusalGate::StatementUnderflow)); + t.record(Err(RefusalGate::PoolFull)); + + assert_eq!(t.survived, 2); + assert_eq!(t.at(RefusalGate::StatementUnderflow), 2); + assert_eq!(t.at(RefusalGate::PoolFull), 1); + // Silence twin: a gate that never fired reports zero, not absence + // masquerading as failure to look it up. + assert_eq!(t.at(RefusalGate::PoolTooWide), 0); + assert_eq!(t.total(), 5); + } + + #[test] + fn nonzero_gates_omits_gates_that_never_fired() { + let mut t = FunnelTally::default(); + t.record(Ok(())); + t.record(Err(RefusalGate::ConformanceDrift)); + let report: Vec<_> = t.nonzero_gates().collect(); + assert_eq!(report, vec![(RefusalGate::ConformanceDrift, 1)]); + // Anti-vacuity: a fully-clean batch reports an EMPTY nonzero list, + // not a list of every gate at zero. + let clean = FunnelTally { + survived: 3, + ..Default::default() + }; + assert_eq!(clean.nonzero_gates().count(), 0); + } + + #[test] + fn real_error_types_map_to_the_gate_that_actually_fired() { + // This is the point: a caller running the real funnel does not + // hand-translate three enums into RefusalGate — `.into()` does it, + // and it must land on the RIGHT gate, not just *a* gate. + let underflow = StatementError::StackUnderflow { + index: 0, + f: crate::FnIndex::ADD, + }; + assert_eq!( + RefusalGate::from(&underflow), + RefusalGate::StatementUnderflow + ); + let dangling = StatementError::DanglingOperands { depth: 2 }; + assert_eq!(RefusalGate::from(&dangling), RefusalGate::StatementDangling); + let full = PoolError::Full; + assert_eq!(RefusalGate::from(&full), RefusalGate::PoolFull); + let drift = ConformanceError::SharedCoreDrift { + f: crate::FnIndex::ADD, + what: "stack_arity", + }; + assert_eq!(RefusalGate::from(&drift), RefusalGate::ConformanceDrift); + } + + #[test] + fn from_results_folds_a_batch_without_hand_rolled_matching() { + let batch: Vec> = vec![ + Ok(()), + Err(StatementError::StackUnderflow { + index: 0, + f: crate::FnIndex::ADD, + }), + Ok(()), + ]; + let t = FunnelTally::from_results(batch.iter()); + assert_eq!(t.survived, 2); + assert_eq!(t.at(RefusalGate::StatementUnderflow), 1); + } +} diff --git a/crates/ogar-loco/src/vocabulary.rs b/crates/ogar-loco/src/vocabulary.rs index bd915c8..f406a83 100644 --- a/crates/ogar-loco/src/vocabulary.rs +++ b/crates/ogar-loco/src/vocabulary.rs @@ -170,6 +170,118 @@ pub mod shared_core { } } + /// The canonical name of a shared-core slot — the prompt-legend / + /// oracle-schema column (wishlist W-2). + /// + /// Names are NOT coverage: refused-but-real palette entries (`WAIT`, + /// `STOP`, …) are named so a legend can say "exists, refused" instead of + /// omitting them into apparent nonexistence. [`FnIndex::NOP`] is not an + /// operation and has no name. Defined once here so no consumer + /// hand-rolls a name map that can drift — a legend becomes a + /// serialization of the validated table itself, at the membrane where + /// serialization is legal. + #[must_use] + pub fn name(f: FnIndex) -> Option<&'static str> { + Some(match f { + FnIndex::IF => "IF", + FnIndex::IF_ELSE => "IF_ELSE", + FnIndex::REPEAT => "REPEAT", + FnIndex::REPEAT_UNTIL => "REPEAT_UNTIL", + FnIndex::WHILE => "WHILE", + FnIndex::FOREVER => "FOREVER", + FnIndex::FOR_EACH => "FOR_EACH", + FnIndex::FOR_RANGE => "FOR_RANGE", + FnIndex::WAIT => "WAIT", + FnIndex::WAIT_UNTIL => "WAIT_UNTIL", + FnIndex::STOP => "STOP", + FnIndex::BREAK => "BREAK", + FnIndex::CONTINUE => "CONTINUE", + FnIndex::RETURN => "RETURN", + FnIndex::AND => "AND", + FnIndex::OR => "OR", + FnIndex::NOT => "NOT", + FnIndex::TRUE => "TRUE", + FnIndex::FALSE => "FALSE", + FnIndex::NULL => "NULL", + FnIndex::TERNARY => "TERNARY", + FnIndex::EQ => "EQ", + FnIndex::NEQ => "NEQ", + FnIndex::LT => "LT", + FnIndex::LTE => "LTE", + FnIndex::GT => "GT", + FnIndex::GTE => "GTE", + FnIndex::ADD => "ADD", + FnIndex::SUB => "SUB", + FnIndex::MUL => "MUL", + FnIndex::DIV => "DIV", + FnIndex::POW => "POW", + FnIndex::MOD => "MOD", + FnIndex::NUMBER => "NUMBER", + FnIndex::ABS => "ABS", + FnIndex::NEG => "NEG", + FnIndex::ROUND => "ROUND", + FnIndex::FLOOR => "FLOOR", + FnIndex::CEIL => "CEIL", + FnIndex::SQRT => "SQRT", + FnIndex::LN => "LN", + FnIndex::LOG10 => "LOG10", + FnIndex::EXP_E => "EXP_E", + FnIndex::EXP_10 => "EXP_10", + FnIndex::SIN => "SIN", + FnIndex::COS => "COS", + FnIndex::TAN => "TAN", + FnIndex::ASIN => "ASIN", + FnIndex::ACOS => "ACOS", + FnIndex::ATAN => "ATAN", + FnIndex::ATAN2 => "ATAN2", + FnIndex::RANDOM_INT => "RANDOM_INT", + FnIndex::RANDOM_FLOAT => "RANDOM_FLOAT", + FnIndex::CONSTRAIN => "CONSTRAIN", + FnIndex::NUMBER_PROPERTY => "NUMBER_PROPERTY", + FnIndex::CONSTANT => "CONSTANT", + FnIndex::ON_LIST => "ON_LIST", + FnIndex::TEXT => "TEXT", + FnIndex::JOIN => "JOIN", + FnIndex::LENGTH => "LENGTH", + FnIndex::CHAR_AT => "CHAR_AT", + FnIndex::INDEX_OF => "INDEX_OF", + FnIndex::IS_EMPTY => "IS_EMPTY", + FnIndex::SUBSTRING => "SUBSTRING", + FnIndex::CHANGE_CASE => "CHANGE_CASE", + FnIndex::TRIM => "TRIM", + FnIndex::CONTAINS => "CONTAINS", + FnIndex::APPEND => "APPEND", + FnIndex::PRINT => "PRINT", + FnIndex::PROMPT => "PROMPT", + FnIndex::COUNT => "COUNT", + FnIndex::REPLACE => "REPLACE", + FnIndex::REVERSE => "REVERSE", + FnIndex::LIST_EMPTY => "LIST_EMPTY", + FnIndex::LIST_WITH => "LIST_WITH", + FnIndex::LIST_REPEAT => "LIST_REPEAT", + FnIndex::LIST_LENGTH => "LIST_LENGTH", + FnIndex::LIST_IS_EMPTY => "LIST_IS_EMPTY", + FnIndex::LIST_INDEX_OF => "LIST_INDEX_OF", + FnIndex::LIST_GET => "LIST_GET", + FnIndex::LIST_SET => "LIST_SET", + FnIndex::LIST_INSERT => "LIST_INSERT", + FnIndex::LIST_ADD => "LIST_ADD", + FnIndex::LIST_DELETE => "LIST_DELETE", + FnIndex::LIST_DELETE_ALL => "LIST_DELETE_ALL", + FnIndex::LIST_SUBLIST => "LIST_SUBLIST", + FnIndex::LIST_SPLIT => "LIST_SPLIT", + FnIndex::LIST_SORT => "LIST_SORT", + FnIndex::LIST_CONTAINS => "LIST_CONTAINS", + FnIndex::VAR_GET => "VAR_GET", + FnIndex::VAR_SET => "VAR_SET", + FnIndex::VAR_CHANGE => "VAR_CHANGE", + FnIndex::PROC_DEF => "PROC_DEF", + FnIndex::PROC_CALL => "PROC_CALL", + FnIndex::PROC_ARG => "PROC_ARG", + _ => return None, + }) + } + /// Whether a covered shared-core call **pushes a result** onto the stack. /// /// `Some(true)` for every covered expression (leaves, unary, binary); @@ -282,6 +394,22 @@ pub trait Vocabulary { self.domain_pushes_result(f) } } + + /// The canonical name of a domain-range function, for legends and + /// oracle schemas. Default `None` — an unnamed slot appears in no + /// legend, which is honest for reserved space. + fn domain_name(&self, _f: FnIndex) -> Option<&'static str> { + None + } + + /// The name of `f` — shared core first, domain hook above the floor. + fn name(&self, f: FnIndex) -> Option<&'static str> { + if f.0 < DOMAIN_FLOOR { + shared_core::name(f) + } else { + self.domain_name(f) + } + } } // ── The canonical data form ───────────────────────────────────────────────── @@ -307,6 +435,11 @@ pub struct FnSpec { /// Whether the call pushes a result; `None` = not declared, so /// statement segmentation refuses rather than guesses. pub pushes_result: Option, + /// The canonical mnemonic, for legends and oracle schemas; `None` = an + /// unnamed (reserved) slot, absent from any legend. Lives IN the spec — + /// OQ-1 answered toward "the table stays the single artifact": a legend + /// is then a serialization of the validated table, nothing beside it. + pub name: Option<&'static str>, } impl FnSpec { @@ -316,6 +449,7 @@ impl FnSpec { body_refs: 0, min_shape: LaneShape::Pairs, pushes_result: None, + name: None, }; } @@ -346,6 +480,7 @@ impl VocabularyTable { body_refs: shared_core::body_refs(f), min_shape: shared_core::min_shape(f), pushes_result: shared_core::pushes_result(f), + name: shared_core::name(f), } } else { FnSpec { @@ -353,6 +488,7 @@ impl VocabularyTable { body_refs: v.domain_body_refs(f), min_shape: v.min_shape(f), pushes_result: v.domain_pushes_result(f), + name: v.domain_name(f), } }; } @@ -394,6 +530,12 @@ impl VocabularyTable { pub fn pushes_result(&self, f: FnIndex) -> Option { self.spec(f).pushes_result } + + /// The canonical mnemonic of `f`; `None` = unnamed (reserved) slot. + #[must_use] + pub fn name(&self, f: FnIndex) -> Option<&'static str> { + self.spec(f).name + } } /// Mechanical conformance: what every vocabulary crate's tests must run. @@ -515,20 +657,48 @@ pub mod conformance { fn pushes_result(&self, f: FnIndex) -> Option { self.table.pushes_result(f) } + fn domain_name(&self, f: FnIndex) -> Option<&'static str> { + self.vocab.domain_name(f) + } + fn name(&self, f: FnIndex) -> Option<&'static str> { + self.table.name(f) + } } /// Validate a vocabulary and, on success, return the proof-carrying /// wrapper program traversal requires — carrying the composed /// [`VocabularyTable`] it was validated as. /// + /// # Compose-then-check (W-1, closing finding F-1) + /// + /// The table is composed FIRST, and the shape invariant is then checked + /// on the **stored table itself** — not only through the method sweep. + /// The earlier order (`check` then `compose`) sampled the domain hooks + /// twice, so a phase-unstable vocabulary could pass the check on one set + /// of answers while the table froze another — including a `body_refs` + /// beyond a call's capacity, re-opening the traversal edge the wrapper's + /// proof exists to close. Now the artifact everything reads is the + /// proven object, to zero resamplings: [`check`]'s method sweep still + /// runs (drift detection inherently reads the methods), and whichever + /// sampling a hostile vocabulary poisons, one of the two gates fires. + /// /// # Errors /// - /// The first [`ConformanceError`] found, naming the byte and the defect - /// — same gate as [`check`], which remains available for test-time - /// assertion without taking ownership. + /// The first [`ConformanceError`] found, naming the byte and the defect. + /// [`check`] remains available for test-time assertion without taking + /// ownership. pub fn validate(v: V) -> Result, ConformanceError> { - check(&v)?; let table = VocabularyTable::compose(&v); + check(&v)?; + // The stored table is the proven object: re-assert the shape + // invariant on the exact bytes the wrapper will carry. + for b in 0..=255u8 { + let f = FnIndex(b); + let spec = table.spec(f); + if spec.min_shape.values_per_call() < usize::from(spec.body_refs) { + return Err(ConformanceError::ShapeTooNarrowForRefs { f }); + } + } Ok(CheckedVocabulary { vocab: v, table }) } @@ -563,6 +733,9 @@ pub mod conformance { what: "pushes_result", }); } + if v.name(f) != shared_core::name(f) { + return Err(ConformanceError::SharedCoreDrift { f, what: "name" }); + } } // Everywhere: the reported minimum shape must actually hold the // call's own body references. @@ -850,6 +1023,120 @@ mod tests { } } + #[test] + fn validate_catches_a_phase_unstable_vocabulary_in_either_phase() { + // THE F-1 regression (wishlist W-1). A vocabulary whose hooks answer + // differently between validate's two samplings must be caught no + // matter WHICH sampling it poisons. Under the old check-then-compose + // order, poisoning the SECOND sampling slipped through: check passed + // on clean answers, then compose froze body_refs=200 into the table + // the wrapper carried — the exact traversal edge the proof fences. + struct UnstableVocab { + poison_on_poll: u32, + polls: core::cell::Cell, + } + impl Vocabulary for UnstableVocab { + fn domain_stack_arity(&self, _f: FnIndex) -> Option { + Some(0) + } + fn domain_body_refs(&self, f: FnIndex) -> u8 { + if f.0 != 0x90 { + return 0; + } + let n = self.polls.get(); + self.polls.set(n + 1); + if n == self.poison_on_poll { 200 } else { 0 } + } + fn min_shape(&self, _f: FnIndex) -> LaneShape { + // Quads, so ONLY a poisoned 200 (never a clean 0) can fail + // the shape invariant — the test isolates instability. + LaneShape::Quads + } + } + let poison_first = UnstableVocab { + poison_on_poll: 0, // compose's sampling gets 200 → stored-table gate + polls: core::cell::Cell::new(0), + }; + assert_eq!( + conformance::validate(poison_first).err(), + Some(ConformanceError::ShapeTooNarrowForRefs { f: FnIndex(0x90) }), + "a poisoned TABLE sampling must be caught by the stored-table gate" + ); + let poison_second = UnstableVocab { + poison_on_poll: 1, // check's sampling gets 200 → method gate + polls: core::cell::Cell::new(0), + }; + assert_eq!( + conformance::validate(poison_second).err(), + Some(ConformanceError::ShapeTooNarrowForRefs { f: FnIndex(0x90) }), + "a poisoned METHOD sampling must be caught by the method gate" + ); + // Silence twin: the same shape with NO poison validates fine. + let stable = UnstableVocab { + poison_on_poll: u32::MAX, + polls: core::cell::Cell::new(0), + }; + assert!(conformance::validate(stable).is_ok()); + } + + #[test] + fn the_name_column_serves_the_legend_and_cannot_drift() { + // W-2: names are not coverage — refused-but-real entries are named + // so a legend can say "exists, refused" rather than omitting them. + assert_eq!(shared_core::name(FnIndex::ADD), Some("ADD")); + assert_eq!(shared_core::name(FnIndex::WAIT), Some("WAIT")); + assert_eq!(shared_core::stack_arity(FnIndex::WAIT), None); + assert_eq!(shared_core::name(FnIndex::NOP), None, "NOP is not an op"); + assert_eq!(shared_core::name(FnIndex(0x1F)), None, "reserved = unnamed"); + + // Composed: shared names ride the table; an undeclared domain slot + // stays unnamed; a declared one is served through the table. + struct NamedDomain; + impl Vocabulary for NamedDomain { + fn domain_stack_arity(&self, f: FnIndex) -> Option { + (f.0 == 0x90).then_some(0) + } + fn domain_body_refs(&self, _f: FnIndex) -> u8 { + 0 + } + fn domain_name(&self, f: FnIndex) -> Option<&'static str> { + (f.0 == 0x90).then_some("OBSERVE") + } + } + let checked = conformance::validate(NamedDomain).unwrap(); + assert_eq!(checked.table().name(FnIndex::IF_ELSE), Some("IF_ELSE")); + assert_eq!(checked.table().name(FnIndex(0x90)), Some("OBSERVE")); + assert_eq!(checked.table().name(FnIndex(0x91)), None); + + // The name is a drift channel like the other three: a vocabulary + // renaming a shared-core op is caught by name. + struct RenamingVocab; + impl Vocabulary for RenamingVocab { + fn domain_stack_arity(&self, _f: FnIndex) -> Option { + None + } + fn domain_body_refs(&self, _f: FnIndex) -> u8 { + 0 + } + fn name(&self, f: FnIndex) -> Option<&'static str> { + if f == FnIndex::ADD { + Some("PLUS") + } else if f.0 < DOMAIN_FLOOR { + shared_core::name(f) + } else { + None + } + } + } + assert_eq!( + check(&RenamingVocab), + Err(ConformanceError::SharedCoreDrift { + f: FnIndex::ADD, + what: "name", + }) + ); + } + #[test] fn conformance_fires_on_a_pushes_drift_too() { // The new column is a new drift channel; it must be guarded like the diff --git a/docs/BLOCK-EDITOR-PLAN.md b/docs/BLOCK-EDITOR-PLAN.md index 194d511..ab374ca 100644 --- a/docs/BLOCK-EDITOR-PLAN.md +++ b/docs/BLOCK-EDITOR-PLAN.md @@ -680,3 +680,44 @@ column or a separate `statement_terminal: bool`. Whichever is chosen must land as a new `FnSpec` column with the same `None`-refuses discipline, and the trap to avoid is letting "discardable" become a silent default that swallows genuinely dangling operands. + +## Consumer wishlist W-1..W-5 — implemented (lance-graph/rig session, 2026-08-05) + +Source: `.claude/handovers/2026-08-05-1430-lance-graph-orchestrator-to-ogar-loco.md` +(consumer-priority order, from the seat that will consume the loco ABI: +lance-graph's rig/rs-graph-llm oracle loop + the compiled-template stack). +All five landed; nothing on #241's gated list was touched. + +- **W-1 (closes finding F-1)** — `validate()` now composes the table FIRST + and re-checks the shape invariant on the STORED table, not only through + the method sweep. The prior order sampled the domain hooks twice + (`check` then `compose`), so a phase-unstable vocabulary could pass on + one set of answers while the frozen table carried another — including a + `body_refs` beyond a call's capacity, reopening the traversal edge + `CheckedVocabulary`'s proof exists to close. Two-directional can-fire + test: poisoning EITHER sampling is caught, by whichever gate sees it. +- **W-2** — the `name` column: `shared_core::name` + `Vocabulary::name`/ + `domain_name`, landing IN `FnSpec` (OQ-1 answered toward "the table + stays the single artifact" — a legend is a serialization of the + validated table, not a second lookup). Names are NOT coverage: refused + entries (`WAIT`, `STOP`, …) are named so a legend can say + "exists, refused." A fourth drift channel, guarded like the other three. +- **W-3** — deferred, as the handover itself specified (ids operator-gated). + Its two forcing constraints are now on record for whoever mints: the + domain range's 112 slots force recipes over atoms; `pushes_result` must + be declared per verb AT mint time, with the silent-mask-coarsening + can-fire test (W-PUSHES-1, docs/#242) riding in alongside it. +- **W-4** — `telemetry::FunnelTally` / `RefusalGate`: a plain data tally + over a generate-and-filter batch's outcomes (`ConformanceError` / + `StatementError` / `PoolError` → gate → count). Validity feedback only, + by design — no scoring, no ranking, no fitness scalar; that boundary + stays lance-graph's (the observer-effect payload law). +- **W-5** — doc-only, in `statements.rs`: the split unit is a sibling + function (never a widened mask — `StepMask` stays `u64` forever), + statement ordinals restart at 0 per split function, the cut falls on a + `statement_bounds` boundary, and which call enters the sibling is the + vocabulary's decision — this crate mints no such call. + +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.