From 4bb89b5001132d281c596aa35c3c8d0b2b4be5ac Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 20:53:41 +0000 Subject: [PATCH 1/2] wip grammar probe --- crates/grammar-constraints-probe/Cargo.toml | 21 + .../grammar-constraints-probe/src/fixtures.rs | 128 +++++ crates/grammar-constraints-probe/src/lib.rs | 526 ++++++++++++++++++ crates/grammar-constraints-probe/tests/p0.rs | 329 +++++++++++ 4 files changed, 1004 insertions(+) create mode 100644 crates/grammar-constraints-probe/Cargo.toml create mode 100644 crates/grammar-constraints-probe/src/fixtures.rs create mode 100644 crates/grammar-constraints-probe/src/lib.rs create mode 100644 crates/grammar-constraints-probe/tests/p0.rs diff --git a/crates/grammar-constraints-probe/Cargo.toml b/crates/grammar-constraints-probe/Cargo.toml new file mode 100644 index 000000000..8e8f0fda7 --- /dev/null +++ b/crates/grammar-constraints-probe/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "grammar-constraints-probe" +version = "0.0.0" +edition = "2021" +publish = false +description = "D-GRAMMAR-CONSTRAINTS-0 lab falsifier: German dative role candidates (recipient / experiencer / direction-purpose) as typed constraints over UD-annotated tokens, evaluated by one mask-risc program per rule over a DR-addressed token population. Lab only; no parser, no production path." + +# Its OWN workspace root, like cognitive-polyfill-probe. Verify via +# cargo test --manifest-path crates/grammar-constraints-probe/Cargo.toml +[workspace] + +[dependencies] +# DR identity: LocalMap / MapTable frames for the token population. +lance-graph-dt64 = { path = "../lance-graph-dt64" } +# The only bulk engine. +lance-graph-mask-risc = { path = "../lance-graph-mask-risc" } +# WHEN expressibility check (SetExpr / Guard normal form). +lance-graph-contract = { path = "../lance-graph-contract" } + +[patch."https://github.com/AdaWorldAPI/ndarray"] +ndarray = { path = "../../../ndarray" } diff --git a/crates/grammar-constraints-probe/src/fixtures.rs b/crates/grammar-constraints-probe/src/fixtures.rs new file mode 100644 index 000000000..1815c11bf --- /dev/null +++ b/crates/grammar-constraints-probe/src/fixtures.rs @@ -0,0 +1,128 @@ +//! Hand-annotated sentences in UD German-GSD convention, and an independent +//! gold role per argument. +//! +//! The annotation convention was checked against the GSD treebank (UD +//! master, CC BY-SA 4.0; fetched for measurement, not committed): +//! +//! - a dative recipient of *geben* is `obl:arg` + `Case=Dat` +//! (`train-s1712`: "gab Shell Mitschuld", Shell `obl:arg` Dat); +//! - a dative experiencer of *gefallen* is ALSO `obl:arg` + `Case=Dat` +//! (`test-s258`: "das gefällt uns", uns `obl:arg` Dat); +//! - `zur` is a multiword token `zu` (`case`) + `der` (`det`, Dat), and its +//! noun is `obl` + `Case=Dat` under *bringen* ("zur Reparatur gebracht"); +//! - `iobj` is not used for these (1 occurrence in GSD train, 0 in dev/test). +//! +//! Gold roles are a separate, single-annotator linguistic analysis (not +//! adjudicated), kept apart from the rules: they are what the rules are +//! scored against, never an input to them. + +use crate::Tok; + +macro_rules! t { + ($gid:expr, $form:expr, $lemma:expr, $deprel:expr, $case:expr, $head:expr) => { + Tok { + gid: $gid, + form: $form, + lemma: $lemma, + deprel: $deprel, + case: $case, + head: $head, + } + }; +} + +/// A. Die Mutter gibt dem Kind das Buch. +pub const A: [Tok; 8] = [ + t!(101, "Die", "der", "det", "Nom", 102), + t!(102, "Mutter", "Mutter", "nsubj", "Nom", 103), + t!(103, "gibt", "geben", "root", "", 0), + t!(104, "dem", "der", "det", "Dat", 105), + t!(105, "Kind", "Kind", "obl:arg", "Dat", 103), + t!(106, "das", "der", "det", "Acc", 107), + t!(107, "Buch", "Buch", "obj", "Acc", 103), + t!(108, ".", ".", "punct", "", 103), +]; + +/// B. Die Mutter bringt das Buch zur Schule. (`zur` = `zu` + `der`) +pub const B: [Tok; 9] = [ + t!(201, "Die", "der", "det", "Nom", 202), + t!(202, "Mutter", "Mutter", "nsubj", "Nom", 203), + t!(203, "bringt", "bringen", "root", "", 0), + t!(204, "das", "der", "det", "Acc", 205), + t!(205, "Buch", "Buch", "obj", "Acc", 203), + t!(206, "zu", "zu", "case", "", 208), + t!(207, "der", "der", "det", "Dat", 208), + t!(208, "Schule", "Schule", "obl", "Dat", 203), + t!(209, ".", ".", "punct", "", 203), +]; + +/// C. Dem Kind gefällt das Buch. +pub const C: [Tok; 6] = [ + t!(301, "Dem", "der", "det", "Dat", 302), + t!(302, "Kind", "Kind", "obl:arg", "Dat", 303), + t!(303, "gefällt", "gefallen", "root", "", 0), + t!(304, "das", "der", "det", "Nom", 305), + t!(305, "Buch", "Buch", "nsubj", "Nom", 303), + t!(306, ".", ".", "punct", "", 303), +]; + +/// D (control). Die Mutter gibt dem Kind mit dem Löffel den Brei. +/// `mit dem Löffel` is a dative `obl` under GIVE that is NOT a recipient. +pub const D: [Tok; 11] = [ + t!(401, "Die", "der", "det", "Nom", 402), + t!(402, "Mutter", "Mutter", "nsubj", "Nom", 403), + t!(403, "gibt", "geben", "root", "", 0), + t!(404, "dem", "der", "det", "Dat", 405), + t!(405, "Kind", "Kind", "obl:arg", "Dat", 403), + t!(406, "mit", "mit", "case", "", 408), + t!(407, "dem", "der", "det", "Dat", 408), + t!(408, "Löffel", "Löffel", "obl", "Dat", 403), + t!(409, "den", "der", "det", "Acc", 410), + t!(410, "Brei", "Brei", "obj", "Acc", 403), + t!(411, ".", ".", "punct", "", 403), +]; + +/// E (control). Die Mutter hilft dem Kind. *helfen* has no frame entry. +pub const E: [Tok; 6] = [ + t!(501, "Die", "der", "det", "Nom", 502), + t!(502, "Mutter", "Mutter", "nsubj", "Nom", 503), + t!(503, "hilft", "helfen", "root", "", 0), + t!(504, "dem", "der", "det", "Dat", 505), + t!(505, "Kind", "Kind", "obl:arg", "Dat", 503), + t!(506, ".", ".", "punct", "", 503), +]; + +/// Gold function of an argument token (Haspelmath dative-map names). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Gold { + Recipient, + Experiencer, + Direction, + /// A dative-map function the frame lexicon does not cover. + Beneficiary, + /// Not a dative-map function (e.g. an instrument). + NotOnMap, +} + +/// The independent gold annotation (single annotator). +pub const GOLD: [(u64, Gold); 6] = [ + (105, Gold::Recipient), + (208, Gold::Direction), + (302, Gold::Experiencer), + (405, Gold::Recipient), + (408, Gold::NotOnMap), + (505, Gold::Beneficiary), +]; + +/// Every sentence, in corpus order. +pub fn corpus() -> Vec { + let mut v = Vec::new(); + for s in [&A[..], &B[..], &C[..], &D[..], &E[..]] { + v.extend_from_slice(s); + } + v +} + +pub fn gids(toks: &[Tok]) -> Vec { + toks.iter().map(|t| t.gid).collect() +} diff --git a/crates/grammar-constraints-probe/src/lib.rs b/crates/grammar-constraints-probe/src/lib.rs new file mode 100644 index 000000000..006ddafe5 --- /dev/null +++ b/crates/grammar-constraints-probe/src/lib.rs @@ -0,0 +1,526 @@ +//! D-GRAMMAR-CONSTRAINTS-0 — grammatical constraints as typed relations over +//! UD-annotated tokens (LAB ONLY). +//! +//! STATUS: PROBE. No parser, no grammar compiler, no production path. The +//! token annotations are hand-written in UD German-GSD convention (see +//! `fixtures`), and the frame lexicon is a declared, versioned fixture. +//! +//! # What is tested +//! +//! Whether a German dative argument's candidate semantic function (one of +//! Haspelmath's dative-map functions, the same eight `causal_edge::Orientation` +//! names) can be selected by ONE mask-risc program per rule, over resident +//! lanes of the token population, with the head hop read in place through +//! `Pred::EqU32Via` (fk = the token's head pointer). In UD German-GSD the +//! recipient of *geben* and the experiencer of *gefallen* carry the SAME +//! `deprel = obl:arg` and `Case=Dat`; only the head verb's frame separates +//! them, so a rule that reads only the dependent's own lanes must fail. +//! +//! # Constraint classes (kept apart) +//! +//! - STRUCTURAL: `deprel`, `head`, `Case` of the token (UD layer, exact). +//! - LANGUAGE-SPECIFIC: the frame lexicon [`FRAMES_VERSION`] (German verbs → +//! role of their `obl:arg` dative), and `zu` + Dat `obl` → {Direction, +//! Purpose}. +//! - HEURISTIC: none is applied: Direction vs Purpose is NOT chosen; both are +//! kept (no selection policy exists). +//! - EVIDENTIAL / EFFECTFUL: none. Every output is a CANDIDATE mask; nothing is +//! written back, nothing is promoted to a fact. +//! +//! # UNKNOWN +//! +//! A dative `obl:arg` whose head has no frame entry is reported as +//! [`Readout::abstain_frame`]; an `obl:arg` with a frame but no `Case` +//! feature is [`Readout::unknown_case`]. Neither is FALSE. +//! +//! # EqU32Via admission +//! +//! `Pred::EqU32Via` executes in mask-risc today, but its Quack call site is +//! EXO-140-unclassified. This probe uses it as a lab measurement of what the +//! algebra can express, not as an admission. + +#![forbid(unsafe_code)] + +pub mod fixtures; + +use std::collections::HashMap; + +use lance_graph_dt64::dr::{Frame, LocalMap, MapRead, MapRefusal, MapTable, TileId, Width}; +use lance_graph_mask_risc::{ + execute_into, words_for, Foreign, LaneRef, MaskOp, Operand, Out, Planes, Pred, Program, + Scratch, Terminal, +}; + +pub const TILE: TileId = 0; +pub const WIDTH: Width = Width::W256; +/// Head pointer of a root token: names no row, so `EqU32Via` does not match +/// and `ScatterOrU32` drops it. +pub const ROOT: u32 = u32::MAX; + +/// Version of the declared language-specific frame lexicon. +pub const FRAMES_VERSION: &str = "de-dative-frames-fixture-v0"; + +// --------------------------------------------------------------------------- +// Cold vocabulary binding: labels → u32 ids (0 = absent / UNKNOWN) +// --------------------------------------------------------------------------- + +/// Label → id, built once (cold). Id 0 is reserved for "no value". +pub struct Vocab { + ids: HashMap, +} + +impl Vocab { + pub fn new() -> Self { + Self { + ids: HashMap::new(), + } + } + + /// Intern a label (cold). The empty label is 0. + pub fn id(&mut self, label: &str) -> u32 { + if label.is_empty() { + return 0; + } + let next = self.ids.len() as u32 + 1; + *self.ids.entry(label.to_string()).or_insert(next) + } + + /// Look up without interning; 0 if unknown. + pub fn get(&self, label: &str) -> u32 { + self.ids.get(label).copied().unwrap_or(0) + } +} + +impl Default for Vocab { + fn default() -> Self { + Self::new() + } +} + +// --------------------------------------------------------------------------- +// Token population +// --------------------------------------------------------------------------- + +/// One UD syntactic word, as annotated (string form; bound cold). +#[derive(Clone, Copy, Debug)] +pub struct Tok { + /// Global identity. + pub gid: u64, + pub form: &'static str, + pub lemma: &'static str, + pub deprel: &'static str, + /// `Case=` value, "" when the feature is absent. + pub case: &'static str, + /// Global id of the head, 0 for the root. + pub head: u64, +} + +/// Lane indices of a [`Population`]. +pub const LEMMA: u16 = 0; +pub const DEPREL: u16 = 1; +pub const CASE: u16 = 2; +pub const HEAD: u16 = 3; +/// Foreign lane indices: the same population read through `head`. +pub const F_LEMMA: u16 = 0; +pub const F_DEPREL: u16 = 1; +pub const F_CASE: u16 = 2; + +/// A token population over one DR tile: four `u32` lanes in local order. +pub struct Population { + pub n: usize, + lemma: Vec, + deprel: Vec, + case: Vec, + head: Vec, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Refusal { + Map(MapRefusal), + Read(MapRead), +} + +impl Population { + /// Bind `toks` (cold) under `frame`: labels through `vocab`, heads through + /// the tile's global → local map. + pub fn bind( + toks: &[Tok], + vocab: &mut Vocab, + table: &MapTable, + frame: &Frame, + ) -> Result { + let map = table.map(frame, TILE).map_err(Refusal::Read)?; + let n = map.len(); + let mut p = Population { + n, + lemma: vec![0; n], + deprel: vec![0; n], + case: vec![0; n], + head: vec![ROOT; n], + }; + for t in toks { + let l = usize::from(map.local(t.gid).map_err(Refusal::Map)?); + p.lemma[l] = vocab.id(t.lemma); + p.deprel[l] = vocab.id(t.deprel); + p.case[l] = vocab.id(t.case); + if t.head != 0 { + p.head[l] = u32::from(map.local(t.head).map_err(Refusal::Map)?); + } + } + Ok(p) + } + + fn lanes(&self) -> [LaneRef<'_>; 4] { + [ + LaneRef::U32(&self.lemma), + LaneRef::U32(&self.deprel), + LaneRef::U32(&self.case), + LaneRef::U32(&self.head), + ] + } + + fn foreign_lanes(&self) -> [LaneRef<'_>; 3] { + [ + LaneRef::U32(&self.lemma), + LaneRef::U32(&self.deprel), + LaneRef::U32(&self.case), + ] + } +} + +/// Build a DR tile of `gids` in a fresh table and seal its frame. +pub fn tile(source: u64, gids: &[u64]) -> (MapTable, Frame) { + let mut t = MapTable::new(source, 0); + let map = LocalMap::prepare(WIDTH, gids).expect("probe tile"); + t.advance(source, 0, vec![(TILE, Some(map))]); + let f = t.seal(); + (t, f) +} + +// --------------------------------------------------------------------------- +// Rules +// --------------------------------------------------------------------------- + +/// The ids a rule set needs, resolved once from the vocabulary. +#[derive(Clone, Copy, Debug)] +pub struct Ids { + pub obl_arg: u32, + pub obl: u32, + pub case_rel: u32, + pub dat: u32, + pub zu: u32, + /// GIVE frame: `obl:arg` dative = Recipient. + pub give: [u32; 2], + /// EXPERIENCE frame: `obl:arg` dative = Experiencer. + pub experience: [u32; 1], +} + +impl Ids { + /// The declared frame lexicon ([`FRAMES_VERSION`]). + pub fn bind(v: &mut Vocab) -> Self { + Ids { + obl_arg: v.id("obl:arg"), + obl: v.id("obl"), + case_rel: v.id("case"), + dat: v.id("Dat"), + zu: v.id("zu"), + give: [v.id("geben"), v.id("schenken")], + experience: [v.id("gefallen")], + } + } +} + +/// Lab switches for the disable runs. All `true` outside a falsifier. +#[derive(Clone, Copy, Debug)] +pub struct Operands { + pub deprel: bool, + pub case: bool, + pub frame: bool, +} + +impl Operands { + pub const ALL: Operands = Operands { + deprel: true, + case: true, + frame: true, + }; +} + +fn pred(p: Pred, dst: u16) -> MaskOp { + MaskOp::Pred { + pred: p, + under: None, + dst, + } +} + +/// `[deprel == obl:arg] ∧ [case == Dat] ∧ head.lemma ∈ verbs`, Keep. +/// A disabled operand is replaced by `deprel != 0` (any token), so the +/// program keeps its shape. +fn dative_arg(ids: &Ids, verbs: &[u32], ops: Operands) -> Program { + let mut v = Vec::new(); + v.push(if ops.deprel { + pred(Pred::EqU32 { lane: DEPREL, v: ids.obl_arg }, 0) + } else { + pred(Pred::NeU32 { lane: DEPREL, v: 0 }, 0) + }); + v.push(if ops.case { + pred(Pred::EqU32 { lane: CASE, v: ids.dat }, 1) + } else { + pred(Pred::NeU32 { lane: DEPREL, v: 0 }, 1) + }); + v.push(MaskOp::And { + a: Operand::Scratch(0), + b: Operand::Scratch(1), + dst: 0, + }); + if ops.frame { + for (i, &verb) in verbs.iter().enumerate() { + let dst = if i == 0 { 1 } else { 2 }; + v.push(pred( + Pred::EqU32Via { + fk: HEAD, + key: F_LEMMA, + v: verb, + }, + dst, + )); + if i > 0 { + v.push(MaskOp::Or { + a: Operand::Scratch(1), + b: Operand::Scratch(2), + dst: 1, + }); + } + } + v.push(MaskOp::And { + a: Operand::Scratch(0), + b: Operand::Scratch(1), + dst: 0, + }); + } + Program::new( + v, + Terminal::Keep { + mask: Operand::Scratch(0), + }, + ) +} + +/// Evaluated at the case marker `t`: `t.deprel == case ∧ t.lemma == zu ∧ +/// head(t).deprel == obl ∧ head(t).case == Dat`; the demanded result is the +/// set of heads (`ScatterOrU32` over `head`): nouns with candidates +/// {Direction, Purpose}. +fn zu_goal(ids: &Ids) -> Program { + let v = vec![ + pred(Pred::EqU32 { lane: DEPREL, v: ids.case_rel }, 0), + pred(Pred::EqU32 { lane: LEMMA, v: ids.zu }, 1), + MaskOp::And { + a: Operand::Scratch(0), + b: Operand::Scratch(1), + dst: 0, + }, + pred( + Pred::EqU32Via { + fk: HEAD, + key: F_DEPREL, + v: ids.obl, + }, + 1, + ), + MaskOp::And { + a: Operand::Scratch(0), + b: Operand::Scratch(1), + dst: 0, + }, + pred( + Pred::EqU32Via { + fk: HEAD, + key: F_CASE, + v: ids.dat, + }, + 1, + ), + MaskOp::And { + a: Operand::Scratch(0), + b: Operand::Scratch(1), + dst: 0, + }, + ]; + Program::new( + v, + Terminal::ScatterOrU32 { + mask: Operand::Scratch(0), + lane: HEAD, + out_rows: WIDTH.positions() as u32, + }, + ) +} + +/// `[deprel == obl:arg] ∧ ¬[head.lemma ∈ every framed verb]`, with the case +/// split: `case == Dat` → frame abstention; `case == 0` (absent) ∧ framed → +/// unknown case. Two programs. +fn abstain_frame(ids: &Ids) -> Program { + let mut v = vec![ + pred(Pred::EqU32 { lane: DEPREL, v: ids.obl_arg }, 0), + pred(Pred::EqU32 { lane: CASE, v: ids.dat }, 1), + MaskOp::And { + a: Operand::Scratch(0), + b: Operand::Scratch(1), + dst: 0, + }, + ]; + push_any_frame(&mut v, ids, 1); + v.push(MaskOp::AndNot { + a: Operand::Scratch(0), + b: Operand::Scratch(1), + dst: 0, + }); + Program::new( + v, + Terminal::Keep { + mask: Operand::Scratch(0), + }, + ) +} + +fn unknown_case(ids: &Ids) -> Program { + let mut v = vec![ + pred(Pred::EqU32 { lane: DEPREL, v: ids.obl_arg }, 0), + pred(Pred::EqU32 { lane: CASE, v: 0 }, 1), + MaskOp::And { + a: Operand::Scratch(0), + b: Operand::Scratch(1), + dst: 0, + }, + ]; + push_any_frame(&mut v, ids, 1); + v.push(MaskOp::And { + a: Operand::Scratch(0), + b: Operand::Scratch(1), + dst: 0, + }); + Program::new( + v, + Terminal::Keep { + mask: Operand::Scratch(0), + }, + ) +} + +/// `dst = head.lemma ∈ give ∪ experience`, using `dst` and `dst + 1`. +fn push_any_frame(v: &mut Vec, ids: &Ids, dst: u16) { + let verbs: Vec = ids.give.iter().chain(ids.experience.iter()).copied().collect(); + for (i, &verb) in verbs.iter().enumerate() { + let d = if i == 0 { dst } else { dst + 1 }; + v.push(pred( + Pred::EqU32Via { + fk: HEAD, + key: F_LEMMA, + v: verb, + }, + d, + )); + if i > 0 { + v.push(MaskOp::Or { + a: Operand::Scratch(dst), + b: Operand::Scratch(dst + 1), + dst, + }); + } + } +} + +/// The five prepared programs, compiled once. +pub struct Rules { + recipient: Program, + experiencer: Program, + goal: Program, + abstain: Program, + unknown: Program, + scratch: Scratch<'static>, +} + +/// Candidate masks over the population's local positions (one bit per +/// token). A token in both `direction_or_purpose` and nothing else carries +/// two candidates: the ambiguity is kept. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Readout { + pub recipient: [u64; 4], + pub experiencer: [u64; 4], + pub direction_or_purpose: [u64; 4], + pub abstain_frame: [u64; 4], + pub unknown_case: [u64; 4], +} + +impl Rules { + pub fn prepare(ids: &Ids, ops: Operands) -> Self { + Rules { + recipient: dative_arg(ids, &ids.give, ops), + experiencer: dative_arg(ids, &ids.experience, ops), + goal: zu_goal(ids), + abstain: abstain_frame(ids), + unknown: unknown_case(ids), + scratch: Scratch::new(words_for(WIDTH.positions()), 3), + } + } + + /// The number of mask ops across the five programs. + pub fn op_count(&self) -> usize { + [ + &self.recipient, + &self.experiencer, + &self.goal, + &self.abstain, + &self.unknown, + ] + .iter() + .map(|p| p.ops.len()) + .sum() + } + + /// Hot: run the five programs over borrowed lanes. Re-checks the frame. + /// Allocates nothing. + pub fn run( + &mut self, + pop: &Population, + table: &MapTable, + frame: &Frame, + ) -> Result { + let map = table.map(frame, TILE).map_err(Refusal::Read)?; + if map.len() != pop.n { + return Err(Refusal::Map(MapRefusal::OutOfTile)); + } + let lanes = pop.lanes(); + let flanes = pop.foreign_lanes(); + let planes = Planes { + n_rows: pop.n, + masks: &[], + lanes: &lanes, + }; + let foreign = Foreign { + planes: &[], + lanes: &flanes, + }; + let mut r = Readout::default(); + let words = words_for(pop.n); + for (prog, out) in [ + (&self.recipient, &mut r.recipient), + (&self.experiencer, &mut r.experiencer), + (&self.goal, &mut r.direction_or_purpose), + (&self.abstain, &mut r.abstain_frame), + (&self.unknown, &mut r.unknown_case), + ] { + let full = matches!(prog.terminal, Terminal::ScatterOrU32 { .. }); + let dst: &mut [u64] = if full { &mut out[..] } else { &mut out[..words] }; + execute_into(prog, &planes, &foreign, &mut self.scratch, Out::Mask(dst)) + .expect("probe program is valid"); + } + Ok(r) + } +} + +/// Bit test on a 256-bit mask. +pub fn has(mask: &[u64; 4], local: u8) -> bool { + mask[usize::from(local) / 64] >> (local % 64) & 1 == 1 +} diff --git a/crates/grammar-constraints-probe/tests/p0.rs b/crates/grammar-constraints-probe/tests/p0.rs new file mode 100644 index 000000000..f257721df --- /dev/null +++ b/crates/grammar-constraints-probe/tests/p0.rs @@ -0,0 +1,329 @@ +//! D-GRAMMAR-CONSTRAINTS-0 falsifier: German A/B/C (+ controls D, E) and the +//! eight negative controls of the brief, each with a predicted outcome. + +use std::alloc::{GlobalAlloc, Layout, System}; +use std::cell::Cell; + +use grammar_constraints_probe::fixtures::{self, Gold, A, B, C, GOLD}; +use grammar_constraints_probe::*; +use lance_graph_contract::set_algebra::{Logic, SetExpr}; +use lance_graph_dt64::dr::{Frame, MapRead, MapTable}; +use lance_graph_dt64::Guard; + +thread_local! { static BYTES: Cell = const { Cell::new(0) }; } +struct Counting; +unsafe impl GlobalAlloc for Counting { + unsafe fn alloc(&self, l: Layout) -> *mut u8 { + let _ = BYTES.try_with(|b| b.set(b.get() + l.size())); + System.alloc(l) + } + unsafe fn dealloc(&self, p: *mut u8, l: Layout) { + System.dealloc(p, l) + } +} +#[global_allocator] +static ALLOC: Counting = Counting; + +// --- harness ---------------------------------------------------------------- + +struct World { + table: MapTable, + frame: Frame, + pop: Population, + rules: Rules, +} + +fn world_with(toks: &[Tok], ops: Operands) -> World { + let (table, frame) = tile(7, &fixtures::gids(toks)); + let mut v = Vocab::new(); + let ids = Ids::bind(&mut v); + let pop = Population::bind(toks, &mut v, &table, &frame).unwrap(); + World { + table, + frame, + pop, + rules: Rules::prepare(&ids, ops), + } +} + +fn world(toks: &[Tok]) -> World { + world_with(toks, Operands::ALL) +} + +/// What the rules said about one global id. +#[derive(Debug, Default, PartialEq, Eq, Clone, Copy)] +struct Said { + recipient: bool, + experiencer: bool, + dir_or_purp: bool, + abstain: bool, + unknown: bool, +} + +impl Said { + fn any_role(&self) -> bool { + self.recipient || self.experiencer || self.dir_or_purp + } +} + +fn said(w: &mut World, gid: u64) -> Said { + let r = w.rules.run(&w.pop, &w.table, &w.frame).unwrap(); + let local = w.table.map(&w.frame, TILE).unwrap().local(gid).unwrap(); + Said { + recipient: has(&r.recipient, local), + experiencer: has(&r.experiencer, local), + dir_or_purp: has(&r.direction_or_purpose, local), + abstain: has(&r.abstain_frame, local), + unknown: has(&r.unknown_case, local), + } +} + +/// Every gid that got any role. +fn roled(w: &mut World, toks: &[Tok]) -> Vec { + toks.iter() + .map(|t| t.gid) + .filter(|&g| said(w, g).any_role()) + .collect() +} + +#[derive(Debug, Default, PartialEq, Eq)] +struct Score { + correct: u32, + false_pos: u32, + false_neg: u32, + abstained: u32, + ambiguous_kept: u32, +} + +fn score(w: &mut World, toks: &[Tok]) -> Score { + let mut s = Score::default(); + for t in toks { + let got = said(w, t.gid); + let gold = GOLD.iter().find(|(g, _)| *g == t.gid).map(|&(_, r)| r); + let hit = match gold { + Some(Gold::Recipient) => got.recipient, + Some(Gold::Experiencer) => got.experiencer, + Some(Gold::Direction) => got.dir_or_purp, + _ => false, + }; + let wrong = (got.recipient && gold != Some(Gold::Recipient)) + || (got.experiencer && gold != Some(Gold::Experiencer)) + || (got.dir_or_purp && gold != Some(Gold::Direction)); + if hit { + s.correct += 1; + } + if wrong { + s.false_pos += 1; + } + if matches!( + gold, + Some(Gold::Recipient | Gold::Experiencer | Gold::Direction) + ) && !got.any_role() + && !(got.abstain || got.unknown) + { + s.false_neg += 1; + } + if got.abstain || got.unknown { + s.abstained += 1; + } + if got.dir_or_purp { + s.ambiguous_kept += 1; // Direction AND Purpose both kept + } + } + s +} + +// --- P0: the three sentences plus D, E --------------------------------------- + +#[test] +fn p0_scores_against_independent_gold() { + let corpus = fixtures::corpus(); + let mut w = world(&corpus); + let s = score(&mut w, &corpus); + assert_eq!( + s, + Score { + correct: 4, + false_pos: 0, + false_neg: 0, + abstained: 1, + ambiguous_kept: 1 + } + ); + // A: recipient; C: experiencer; B: direction-or-purpose, never recipient. + assert!(said(&mut w, 105).recipient && !said(&mut w, 105).experiencer); + assert!(said(&mut w, 302).experiencer && !said(&mut w, 302).recipient); + let b = said(&mut w, 208); + assert!(b.dir_or_purp && !b.recipient && !b.experiencer); + // D: the dative instrument under GIVE gets nothing. + assert_eq!(said(&mut w, 408), Said::default()); + // E: helfen has no frame: UNKNOWN (abstain), not FALSE, not a guess. + let e = said(&mut w, 505); + assert!(e.abstain && !e.any_role()); +} + +#[test] +fn surface_case_alone_does_not_determine_the_role() { + // Kind in A and Kind in C have identical own lanes (obl:arg, Dat). + let (a, c) = (A[4], C[1]); + assert_eq!((a.deprel, a.case), (c.deprel, c.case)); + let corpus = fixtures::corpus(); + let mut w = world(&corpus); + assert_ne!(said(&mut w, a.gid), said(&mut w, c.gid)); +} + +// --- negative controls (brief §5) -------------------------------------------- + +/// 1. GIVE → LIKE with the dative marking unchanged. +#[test] +fn c1_give_replaced_by_like() { + let mut a = A; + a[2].lemma = "gefallen"; + let mut w = world(&a); + let k = said(&mut w, 105); + assert!(!k.recipient && k.experiencer, "{k:?}"); +} + +/// 2. The verb-frame constraint removed: predicted false positives in C and E. +#[test] +fn c2_without_the_frame_constraint() { + let corpus = fixtures::corpus(); + let mut w = world_with( + &corpus, + Operands { + frame: false, + ..Operands::ALL + }, + ); + let s = score(&mut w, &corpus); + // Recipient fires on 302 and 505, experiencer on 105, 405, 505. + assert!(said(&mut w, 302).recipient && said(&mut w, 105).experiencer); + assert_eq!(s.false_pos, 4, "{s:?}"); // 105, 302, 405, 505 +} + +/// 3. Dependency direction swapped (Kind heads the verb). +#[test] +fn c3_swapped_dependency_direction() { + let mut a = A; + a[2].head = 105; + a[2].deprel = "obl:arg"; + a[2].case = ""; + a[4].head = 0; + a[4].deprel = "root"; + let mut w = world(&a); + assert!(roled(&mut w, &a).is_empty()); +} + +/// 4. Only surface word order changes: "Das Buch gibt die Mutter dem Kind." +#[test] +fn c4_word_order_changes_nothing() { + let reordered = [A[5], A[6], A[2], A[0], A[1], A[3], A[4], A[7]]; + let mut w1 = world(&A); + let mut w2 = world(&reordered); + for t in &A { + assert_eq!(said(&mut w1, t.gid), said(&mut w2, t.gid), "{}", t.form); + } + // anti-vacuity: the local position of Kind really moved + let l1 = w1.table.map(&w1.frame, TILE).unwrap().local(105).unwrap(); + let l2 = w2.table.map(&w2.frame, TILE).unwrap().local(105).unwrap(); + assert_ne!(l1, l2); +} + +/// 5. Ambiguous attachment: "zur Schule" under the verb (obl) or under +/// "Buch" (nmod). Two readings, two populations; neither is merged away. +#[test] +fn c5_ambiguous_attachment_keeps_both_readings() { + let verb_reading = B; + let mut noun_reading = B; + noun_reading[7].deprel = "nmod"; + noun_reading[7].head = 205; + let mut w1 = world(&verb_reading); + let mut w2 = world(&noun_reading); + assert!(said(&mut w1, 208).dir_or_purp); + assert!(!said(&mut w2, 208).dir_or_purp); +} + +/// 6. A supporting observation deleted: Kind's Case feature removed. +#[test] +fn c6_missing_case_is_unknown_not_false() { + let mut a = A; + a[4].case = ""; + let mut w = world(&a); + let k = said(&mut w, 105); + assert!(!k.recipient && k.unknown && !k.abstain, "{k:?}"); + // and deleting the zu case marker removes the goal candidate + let b: Vec = B.iter().copied().filter(|t| t.gid != 206).collect(); + let mut wb = world(&b); + assert!(!said(&mut wb, 208).dir_or_purp); +} + +/// 7. A stale (foreign) coordinate frame is refused. +#[test] +fn c7_foreign_frame_refuses() { + let mut w = world(&A); + let (_other, foreign) = tile(7, &fixtures::gids(&A)); + assert!(matches!( + w.rules.run(&w.pop, &w.table, &foreign), + Err(Refusal::Read(MapRead::Stale(_))) + )); +} + +/// 8. One required relational operand disabled: deprel. The dative +/// instrument `mit dem Löffel` (obl, Dat, under geben) becomes a recipient. +#[test] +fn c8_disabled_deprel_operand() { + let corpus = fixtures::corpus(); + let mut w = world_with( + &corpus, + Operands { + deprel: false, + ..Operands::ALL + }, + ); + assert!(said(&mut w, 408).recipient); + // and the article "dem" (det, Dat, head Kind) is NOT, because its head is + // not a GIVE verb: the frame hop still holds + assert!(!said(&mut w, 404).recipient); +} + +// --- WHEN expressibility (DT64 Guard / SetExpr) ------------------------------- + +#[test] +fn when_guard_cannot_state_the_head_frame() { + // The dependent's own conditions are a SetExpr over two lanes. + let own = SetExpr::And(vec![ + SetExpr::eq_u32(DEPREL, 1), + SetExpr::eq_u32(CASE, 2), + ]); + let recipient = Guard::new(own.clone(), Logic::TwoValued); + let experiencer = Guard::new(own, Logic::TwoValued); + // With only single-population leaves, the two rules have the SAME guard + // witness: WHEN cannot tell A from C. The frame lives one hop away (the + // head's lemma) and SetExpr has no hop leaf; only an opaque Sym(k), whose + // meaning the consumer supplies, could stand in for it. + assert_eq!(recipient.witness(), experiencer.witness()); + let with_sym = |k| { + Guard::new( + SetExpr::And(vec![SetExpr::eq_u32(DEPREL, 1), SetExpr::Sym(k)]), + Logic::TwoValued, + ) + }; + assert_ne!(with_sym(1).witness(), with_sym(2).witness()); +} + +// --- cost --------------------------------------------------------------------- + +#[test] +fn hot_run_allocates_nothing() { + let corpus = fixtures::corpus(); + let mut w = world(&corpus); + let _ = w.rules.run(&w.pop, &w.table, &w.frame).unwrap(); + let before = BYTES.with(|b| b.get()); + let mut n = 0u32; + for _ in 0..1000 { + let r = w.rules.run(&w.pop, &w.table, &w.frame).unwrap(); + n += r.recipient.iter().map(|x| x.count_ones()).sum::(); + } + assert_eq!(n, 2000); // 105 and 405, every run + assert_eq!(BYTES.with(|b| b.get()) - before, 0); +} From ba63ef8aebc40098afe78ba8ed6d495af91a4b95 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 20:58:41 +0000 Subject: [PATCH 2/2] D-GRAMMAR-CONSTRAINTS-0: German dative roles as typed relational constraints (lab) Research crosswalk of Constraint Grammar 3, Universal Dependencies, Haspelmath semantic maps, Grammatical Framework, the 2026 Bayesian universals study and spaCy's matchers, against DR / RelAlg / mask-risc and DT64 ON/WHEN/DO. Completes the lab probe crates/grammar-constraints-probe (own workspace, excluded from the root workspace) started in the previous commit. - UD German-GSD gives the recipient of geben and the experiencer of gefallen the same annotation (obl:arg + Case=Dat), so a rule over the dependent's own lanes cannot separate them; the head hop decides. - One mask-risc program per rule, head read in place via Pred::EqU32Via: 4/4 against gold, 0 false positives, 0 false negatives, 1 UNKNOWN abstention, Direction/Purpose ambiguity kept, 0 B on the hot path, ~1.2 us for five rules over 40 tokens; 8 negative controls as predicted, 9 source disables red. - First missing interface: DT64 WHEN (SetExpr) has no hop leaf, and R2IL refuses WHEN execution (GuardNotExecutable). - P1 census (English EWT, Latin PROIEL): same frame x slot shape, slot realization language-specific. No parser, no new ClassId or F### word, no production change. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XAqKMLBmi7EtDd15CGwGb8 --- .../2026-11-03-d-grammar-constraints-0.md | 25 ++ .claude/board/entries/README.md | 3 +- .../REPORT.md | 355 ++++++++++++++++++ Cargo.toml | 3 + .../examples/cost.rs | 53 +++ crates/grammar-constraints-probe/src/lib.rs | 69 +++- crates/grammar-constraints-probe/tests/p0.rs | 26 +- 7 files changed, 512 insertions(+), 22 deletions(-) create mode 100644 .claude/board/entries/2026-11-03-d-grammar-constraints-0.md create mode 100644 .claude/probes/2026-11-03-d-grammar-constraints/REPORT.md create mode 100644 crates/grammar-constraints-probe/examples/cost.rs diff --git a/.claude/board/entries/2026-11-03-d-grammar-constraints-0.md b/.claude/board/entries/2026-11-03-d-grammar-constraints-0.md new file mode 100644 index 000000000..a88a7fd78 --- /dev/null +++ b/.claude/board/entries/2026-11-03-d-grammar-constraints-0.md @@ -0,0 +1,25 @@ +# D-GRAMMAR-CONSTRAINTS-0: grammatical constraints as typed relations over UD tokens (2026-11-03) + +**Status:** measured, lab only. Probe `crates/grammar-constraints-probe` (own workspace); report `.claude/probes/2026-11-03-d-grammar-constraints/REPORT.md`. No production change, no new ClassId or `F###` word. + +## Measured + +- **UD German-GSD annotates two different roles identically.** The recipient of *geben* (31) and the experiencer of *gefallen* (18) are both `obl:arg` + `Case=Dat`. `iobj` occurs once in train. UD's German docs say this verbatim: "verbs of giving … have one oblique dative argument". So `Case=Dat → Recipient` is false, and so is `obl:arg+Dat → Recipient`. +- **Each role rule is one mask-risc program over DR-addressed token lanes.** The head hop is read in place with `Pred::EqU32Via` (fk = HEAD). + - Score against gold: 4/4 correct, 0 false positives, 0 false negatives, 1 abstention (UNKNOWN, not FALSE). + - The Direction/Purpose ambiguity is kept. + - 0 bytes allocated on the hot path; about 1.2–1.3 µs for 5 rules over 40 tokens. + - All 8 negative controls behave as predicted; 9 source disables go red. +- **P1 census:** the constraint shape is frame(head) × slot in German, Latin (PROIEL) and English (EWT), but the slot is language-specific. + - Latin `do`: recipient `obl:arg`+Dat (407). + - English `give`: recipient `iobj` (197) or `obl`+*to* (23). + - English `like`: the experiencer is `nsubj` (191). + +## Open (owner) + +- **First missing interface:** the DT64 WHEN (`SetExpr`) has no hop leaf, so the recipient and experiencer guards get equal witnesses. Even if the guard were statable, R2IL refuses it (`GuardNotExecutable`; 3VL is also refused). + - Executing the hop needs `EqU32Via`, whose call sites are EXO-140-unclassified. +- **Where the frame lexicon lives.** The language-specific frame lexicon is unversioned in any contract. +- **Which role vocabulary is canonical.** CE64 orientation has `Core` + 7; deepnsm-v2's `haspelmath` list has 8 and includes Judicantis. +- **Ambiguity cannot live in one CE64 word.** The orientation field holds one role; an alternative set needs masks or Bag candidates plus an explicit selection policy. +- **CG-3's REMOVE/SELECT is ordered and non-monotone** (the last-reading guard). It is effectful DO, not a derived relation. Parked. diff --git a/.claude/board/entries/README.md b/.claude/board/entries/README.md index d052509f5..32bea6719 100644 --- a/.claude/board/entries/README.md +++ b/.claude/board/entries/README.md @@ -25,11 +25,12 @@ index row, (3) no duplicate entry id. Checks 1 and 2 are deliberately opposite directions; the stranding this convention prevents shows up in exactly one of them, never both. -565 entries, 2026-08-06 .. 2026-11-03. +566 entries, 2026-08-06 .. 2026-11-03. | date | entry id | finding | file | |---|---|---|---| | 2026-11-03 | `D-XLOG-META-WIRE-P0` | | [2026-11-03-d-xlog-meta-wire-p0.md](2026-11-03-d-xlog-meta-wire-p0.md) | +| 2026-11-03 | `D-GRAMMAR-CONSTRAINTS-0` | | [2026-11-03-d-grammar-constraints-0.md](2026-11-03-d-grammar-constraints-0.md) | | 2026-11-03 | `D-GLOBAL-WHERE` | | [2026-11-03-d-global-where-p0.md](2026-11-03-d-global-where-p0.md) | | 2026-11-03 | `D-DR-FOLD-MAP` | | [2026-11-03-d-dr-fold-map.md](2026-11-03-d-dr-fold-map.md) | | 2026-11-03 | `D-COGNITIVE-POLYFILL` | | [2026-11-03-d-cognitive-polyfill-p0.md](2026-11-03-d-cognitive-polyfill-p0.md) | diff --git a/.claude/probes/2026-11-03-d-grammar-constraints/REPORT.md b/.claude/probes/2026-11-03-d-grammar-constraints/REPORT.md new file mode 100644 index 000000000..3b6d22028 --- /dev/null +++ b/.claude/probes/2026-11-03-d-grammar-constraints/REPORT.md @@ -0,0 +1,355 @@ +# D-GRAMMAR-CONSTRAINTS-0 — grammatical constraints × RelAlg × CE64/DT64 + +**Date:** 2026-11-03. **Status:** RESEARCH + LAB PROBE. +- Probe: `crates/grammar-constraints-probe`. It has its own workspace and is + excluded from the root workspace. +- No parser, no grammar compiler, no new ClassId or `F###` word, no + production change. + +**Question:** can a grammatical constraint be stated as a typed relation over +existing observations and answered by the existing DR / RelAlg / mask-risc +fold? And where does the declaration language (CG-3, spaCy, UD, GF, MeTTa) +stop being the same thing as the fold? + +**Answer, short:** +- **Yes, for the read-only selection part.** In the probe, one mask-risc + program per rule answers German dative role candidates from UD-annotated + tokens: + - correct 4/4, 0 false positives, 0 false negatives, 1 abstention; + - Direction/Purpose ambiguity kept; + - 0 bytes allocated on the hot path; + - ≈1.2–1.3 µs for all five rules over 40 tokens; + - 8 negative controls behave as predicted; 9 source disables go red. +- **The load-bearing operand is the head hop**, `Pred::EqU32Via` with + fk = HEAD. In UD German the recipient of *geben* and the experiencer of + *gefallen* are both `obl:arg` + `Case=Dat`, so no constraint on the + dependent's own lanes can tell them apart. +- **First missing interface:** DT64's WHEN (`Guard` over `SetExpr`) has no + hop leaf, so it can state the dependent's conditions but not the head's + frame. Even a guard it can state is refused by R2IL `prepare` as + `GuardNotExecutable`. Executing the hop needs the EXO-140-unclassified + `EqU32Via` path. + +--- + +## A. Research synergy map + +Each mechanism below lists what the source says, then what it implies here. +Q = quoted, P = paraphrased. Sources were fetched 2026-11-03. The UD +statements were also checked against the treebank. + +1. **CG-3 is a reading-elimination system with an ordered, non-monotone + state.** (manual, Rules chapter) + - SELECT "Deletes all readings in the cohort except the ones matching the + target set" (Q). + - REMOVE "Deletes the readings matching the target set" (Q). + - "Normally, REMOVE and IFF cannot remove the last reading of a cohort" + (Q). + - Sections run in "ascending only" order (Q). Changes by + SELECT/REMOVE/IFF/… "will rerun the sections" (Q), and `--max-runs` + "defaults to unlimited" (Q). + - MAP/ADD append tags; SETPARENT/SETCHILD add dependency links, with loop + protection unless ALLOWLOOP (Q). + - **Implication:** CG-3's *contextual tests* are relational selections: + offsets, `*`/`**` scans, BARRIER, LINK, NOT/NEGATE (P). Its *rule + application* is an ordered state transition: the last-reading guard + makes the result depend on rule order, which monotone Datalog cannot + express. + - CG-3 does look ON/WHEN/DO-shaped: target ≈ ON, contextual tests ≈ WHEN, + the action ≈ DO. But its DO mutates a shared reading set in order. That + is DT64's ordered-effect DO, not a pure derived relation, so the + resemblance does not prove execution equivalence. +2. **UD separates an exact tree from an annotation convention.** + - The basic layer "forms a tree, where exactly one word is the head" (Q, + u/overview/syntax). Enhanced graphs may have multiple heads, cycles and + empty nodes (Q). + - German UD: "Bare dative and genitive objects … are considered oblique. + This means that verbs of giving are not ditransitive predicates in + German UD, as they have one oblique dative argument" (Q, verified + verbatim from universaldependencies.org/de). + - Validation levels: L1–L3 are language-independent; L4–L5 hold + language-specific labels and rules (P). + - **Implication:** "one head per word" is STRUCTURAL and makes HEAD a + functional fk, which is what lets `EqU32Via` read the head's lanes in + place. `obl:arg` vs `iobj` is a LANGUAGE-SPECIFIC convention. +3. **Haspelmath's map is a co-expression constraint, not a role assigner.** + - "the functions must be arranged in such a way that all multifunctional + grams can occupy a contiguous area" (p. 217, Q). + - "every semantic map can be interpreted as making a universal claim … + that can be falsified easily" (p. 232, Q). + - **Implication:** the map constrains which function *sets* one form may + cover; `haspelmath::continuity` already tests this. It never assigns a + function to a token, so "Case=Dat → Recipient" is not something the map + licenses. The census and the probe falsify it below. +4. **GF types the frame, not the surface.** + - RGL German: `accdatV3 : V -> V3` "geben + dat(c2) + acc(c3)" (Q, + synopsis). English: `mkV3` "ditransitive, e.g. give,_,_" (Q). + - One grammar parses and linearizes (P, tutorial). + - **Implication:** the frame lexicon (verb → which slot plays which role) + is the reusable, typed object. Realization (case, adposition, order) + belongs to the concrete grammar. This matches the P1 census below. +5. **Universals are mostly tendencies.** Verkerk et al. 2026, Nat. Hum. + Behav. 10:126–136: + - "We find statistical support for about a third of the proposed + linguistic universals" (Q). + - Approximate counts, taken from the summarizer and not verbatim: 191 + proposed universals → 89 under spatiophylogenetic control → 60 also + co-evolving. + - **Implication:** typological universals are HEURISTIC or EVIDENTIAL + priors. They must never become a hard WHEN. +6. **spaCy's DependencyMatcher is a conjunctive graph pattern with node + variables.** + - `RIGHT_ID` / `LEFT_ID` name nodes; operators `<`, `>`, `<<`, `>>` and + sibling operators (Q). + - Returns "A list of (match_id, token_ids)" (Q), i.e. binding tuples. + - EntityRuler resolves overlaps by length (P). + - German models use TIGER labels (`da`, `oa`, `sb`), not UD (P, model + meta). + - **Implication:** `<`/`>` is exactly the HEAD fk hop. `<<`/`>>` is + recursion (EXO-73). Overlap policy is HEURISTIC selection. A spaCy + German rule would need a TIGER→UD label crosswalk first. + +## B. Constraint classification + +| class | in this probe | existing contract that carries it | +|---|---|---| +| STRUCTURAL | HEAD (one per word), DEPREL, Case features | resident `u32` lanes; HEAD as fk | +| LANGUAGE-SPECIFIC | frame lexicon `de-dative-frames-fixture-v0`; `zu`+Dat `obl` → {Direction, Purpose} | none is versioned in a contract; it is a probe constant (OPEN) | +| HEURISTIC | not applied: Direction vs Purpose is not chosen | none; a selection policy would be an owner decision | +| EVIDENTIAL | none (annotations are inputs, not NAL truths) | NAL `Beliefs` exists but is NOT used: an annotation is not a truth value | +| EFFECTFUL | none (outputs are candidate masks; nothing is written) | DT64 ordered `effects`; CG-3 REMOVE/MAP live here | + +Also: +- **UNKNOWN is kept apart.** A missing `Case` gives `unknown_case`; a + verb with no frame gives `abstain_frame`. Neither is FALSE. +- **R2IL refuses both kinds of guard.** A three-valued guard + (`Truth::ThreeValued`) is refused at `lance-graph-r2il/src/binding.rs:293`, + right after `GuardNotExecutable` at `:291`. + +## C. Source-backed reuse map + +| capability | where | status | +|---|---|---| +| Typed role vocabulary (Direction, Recipient, Experiencer, …) | `causal-edge/src/orientation.rs:17` (CE64 bits 43..45, `Core`+7); `deepnsm-v2/src/haspelmath.rs:27` (8 incl. Judicantis) | EXISTING, two different 8-element vocabularies (#1917, consolidated doc line 213) | +| Map contiguity check | `deepnsm-v2/src/haspelmath.rs:80` `continuity` | EXISTING, tested | +| Head hop read in place | `lance-graph-mask-risc/src/ir.rs:158` `Pred::EqU32Via`; Quack `Filter::EqU32Via` `lance-graph-quack/src/lib.rs:358` | EXECUTES; call sites EXO-140-UNCLASSIFIED | +| Hop as demanded result (case marker → its noun) | `ir.rs:338` `Terminal::ScatterOrU32` | EXISTING | +| Dependent-own WHEN | `lance-graph-contract/src/set_algebra.rs:135` `SetExpr`; DT64 `Guard` (`lance-graph-dt64/src/lib.rs:152`) | EXPRESSIBLE, but no hop leaf; the head frame needs an opaque `Sym(k)` | +| WHEN execution | `lance-graph-r2il/src/binding.rs:291` | REFUSED (`GuardNotExecutable`); 3VL refused at `:293` | +| DR identity and stale frame | `lance-graph-dt64/src/dr.rs` `LocalMap` / `MapTable::map` | EXISTING, used | +| Multi-reading parse | `deepnsm-v2/src/fsm.rs:904` `parse_readings` | EXISTING; not wired to UD HEAD/DEPREL (#1917, consolidated doc line 192) | +| Role as ONE CE64 field | `orientation.rs` | 3 bits = one role per edge; it cannot hold {Direction, Purpose}. Ambiguity needs two candidate edges or a mask, never one CE64 word | +| Recursion (`<<`, `**`) | relalg `μ` not built | BLOCKED (EXO-73) | + +**First missing caller:** nothing turns UD CoNLL-U into DR-addressed token +lanes. The probe binds by hand (`Population::bind`), cold. + +**Where the existing pieces stop:** +- *The first missing interface* is a WHEN leaf that names the head hop. A + DT64 binding can carry the dependent's conditions. The rule's meaning + (which verb frame) is lost: `when_guard_cannot_state_the_head_frame` + shows the recipient and experiencer guards have equal witnesses. +- *Even a guard DT64 can state cannot run:* R2IL refuses it. + +**ON/WHEN/DO verdict:** +- ON: the trigger (a token/annotation change) has no producer (#1942). +- WHEN: expressible for single-row conditions only. +- DO: a candidate role is a *pure derived relation* (a returned mask). A + write is not needed, so EFFECTFUL DO must not be used for it. + +**Shared WHERE scope:** all five rules read one population under one frame. +No rule consumes another rule's result, so no method-to-method mask handoff +exists (consistent with D-GLOBAL-WHERE, #1943). + +## D. P0 falsifier — specification and results + +**Fixture.** `src/fixtures.rs`, UD German-GSD convention, checked against +GSD master. GSD is CC BY-SA 4.0 and was fetched for measurement only. + +**GSD census (train+dev+test), dative argument deprels:** + +| verb | `obl:arg` + `Case=Dat` | +|---|---| +| *geben* | 31 (e.g. train-s1712) | +| *gefallen* | 18 (e.g. test-s258) | +| *helfen* | 15 | +| *schenken* | 6 | + +- `iobj` occurs once in train and never in dev/test. +- `zur`/`zum` are multiword tokens (599 / 726). +- *bringen* + `zu`… is `obl` + `Dat` (48), e.g. "zur Reparatur gebracht", + which is a **purpose**. "zur Schule bringen" is a **direction** with the + identical surface. + +**Sentences:** +- A *Die Mutter gibt dem Kind das Buch.* +- B *… bringt das Buch zur Schule.* +- C *Dem Kind gefällt das Buch.* +- D (control) *… gibt dem Kind mit dem Löffel den Brei.* +- E (control) *… hilft dem Kind.* + +**Gold** (independent, single annotator, not adjudicated): +- Kind/A = Recipient +- Schule/B = Direction +- Kind/C = Experiencer +- Kind/D = Recipient +- Löffel/D = not on the map (instrument) +- Kind/E = Beneficiary (outside the frame lexicon) + +**Rules.** Each is one prepared program; 37 ops in total. +- Recipient: `deprel=obl:arg ∧ Case=Dat ∧ head.lemma ∈ GIVE{geben, schenken}` + (`EqU32Via`). +- Experiencer: the same with EXPERIENCE{gefallen}. +- Direction-or-Purpose: evaluated at the case marker, + `deprel=case ∧ lemma=zu ∧ head.deprel=obl ∧ head.Case=Dat`, with result + `ScatterOrU32(head)`. +- Abstain: `obl:arg ∧ Dat ∧ ¬framed`. +- Unknown: `obl:arg ∧ no Case ∧ framed`. + +**Comparison of declarations** (the same Recipient rule): +- CG-3, sketch, not executed: + `MAP (@Recipient) TARGET (Dat) IF (0 (obl:arg)) (p ("geben"))`. Here `p` + is the parent test, which needs the dependency-aware VISL extensions. +- spaCy, sketch: `{RIGHT_ID:"v", RIGHT_ATTRS:{LEMMA:"geben"}} → + {LEFT_ID:"v", REL_OP:">", RIGHT_ID:"r", RIGHT_ATTRS:{DEP:"obl:arg", + MORPH:{IS_SUPERSET:["Case=Dat"]}}}`. A German model would emit TIGER `da`, + not UD. +- MeTTa: + `(match &tok (, (dep $v $n obl:arg) (case $n Dat) (lemma $v geben)) $n)`. +- **All three share the conjunctive core that the native fold runs.** What + differs is what happens after a match: CG-3 *mutates* readings, spaCy + returns *bindings*, MeTTa returns a *BindingsSet multiset*. None of those + post-match semantics is in the fold. + +**Results** (`cargo test --manifest-path crates/grammar-constraints-probe/Cargo.toml`): +12/12. + +| measure | result | +|---|---| +| Correct vs gold | 4/4 | +| False positives | 0 | +| False negatives | 0 | +| Abstained (UNKNOWN) | 1 (E, helfen) | +| Ambiguity kept | 1 (B: Direction and Purpose both, no selection) | +| D instrument | no role | +| A vs C | same own lanes (`obl:arg`, `Dat`), different roles | + +**Negative controls** (each predicted, each holds): + +| # | control | predicted and observed | +|---|---|---| +| 1 | GIVE → LIKE (A's verb lemma swapped, dative unchanged) | Recipient gone, Experiencer appears | +| 2 | frame operand removed | 4 false positives (105, 302, 405, 505) | +| 3 | dependency direction swapped | no role anywhere | +| 4 | word order only ("Das Buch gibt die Mutter dem Kind") | identical roles by global id; the local position really moved | +| 5 | ambiguous attachment (PP under verb `obl` vs under noun `nmod`) | two readings, two populations: goal candidate in one, none in the other; not merged | +| 6 | supporting observation deleted | Case removed from Kind → UNKNOWN (not FALSE); `zu` deleted → no goal; Case removed from Schule → no goal | +| 7 | stale (foreign) frame | `Stale` refusal | +| 8 | `deprel` operand disabled | the instrument Löffel becomes a Recipient (false positive) | + +**Source disables.** Each anchor was asserted present, and each disable +compiles. Nine guards were disabled, and every one turns ≥1 test red: +- frame hop: 5 tests red; +- `zu` marker: 2 red; +- head `obl`: c5; +- head `Dat`: c6; +- abstain `AndNot`: p0; +- unknown-case: c6, p0; +- frame check: c7; +- scatter lane: 3 red. + +**Two disables first printed nothing, and both were caught:** +- *A compile error.* The first splice left a stray `}`. A disable that does + not compile looks like a guard that does not bind. +- *A genuinely inert guard.* The head-`Dat` guard had no fixture that could + expose it; the Schule-without-Case control was added for it. + +**Cost** (`cargo run --release --example cost`, Xeon @ 2.10 GHz, 4 cores): + +| phase | cost | +|---|---| +| Cold, reported not gated: vocab bind | ≈3.4 µs | +| Cold: DR map | ≈5.1 µs | +| Cold: population bind | ≈6.9 µs | +| Cold: rule prepare | ≈4.1 µs | +| Hot: all five programs incl. frame re-check | 1.19–1.33 µs/run over 40 tokens (best of 5 × 100k, three runs) | + +- Cold numbers are single measurements, so they are noisy. +- Hot allocation: 0 bytes. +- Binding enumeration is not measured: the answers are masks. Reading out + a role's tokens is the caller's `LocalMap::global` walk. +- No comparison with spaCy or DuckDB: no equivalent workload was run. + +## P1 — cross-linguistic census (measured; not an executable test) + +UD English-EWT is CC BY-SA. UD Latin-PROIEL is CC BY-NC-SA. Both were used +for measurement only; neither is committed. Counts are dependents of the +verb, by `(deprel, Case, case-marker)`: + +| verb / function | German GSD | Latin PROIEL | English EWT | +|---|---|---|---| +| GIVE recipient | *geben* `obl:arg`+Dat 31 | *do* `obl:arg`+Dat 407 | *give* `iobj` 197 (161 with Case=Acc) · `obl`+*to* 23 | +| EXPERIENCE | *gefallen* `obl:arg`+Dat 18 | *placeo* `obl:arg`+Dat 63 | *like*: experiencer is `nsubj` 191 | + +**Reading:** +- The constraint *shape* holds in all three languages: role = frame(head + verb) × argument slot. +- The *slot* is language-specific: + - German and Latin share `obl:arg`+Dat; + - English splits the recipient over `iobj` and `obl`+*to*; + - English realizes the experiencer of LIKE as the **subject** (the + *please*-type verb would put it elsewhere). +- So a reusable abstract relation needs a per-language slot map, which is + GF's concrete-syntax role. A shared surface rule would be wrong. + +**Not verified:** +- No executable P1 tests. +- No Arabic. +- Passive voice and null subjects were not tested. + +## E. Proposed next steps (smallest connection first) + +1. **WIRE (lab), owner D-DT64-6 / EXO-140.** Decide whether a WHEN leaf may + name a head-fk equality (`EqU32Via` over the population's own HEAD lane). + It is the one operand the probe shows is load-bearing. Falsifier: + controls 1, 2 and 8 above. +2. **PROBE.** Add a CoNLL-U → DR token-lane adapter, a cold one-time bind, + and run the five rules over all GSD *geben* / *gefallen* / *helfen* + tokens. Report precision, coverage and abstention against an independent + role gold set. That gold set does not exist in UD and needs a sourced + annotation (e.g. a valency lexicon) **before** any claim. +3. **OWNER DECISION.** Where a versioned language-specific frame lexicon + lives: OGAR codebook / DT64 class, or not at all. Also: which of the two + 8-element role vocabularies is canonical for a *candidate* role. +4. **OWNER DECISION.** Candidate roles stay masks or Bag candidates. One + CE64 orientation field cannot carry an alternative set, and promoting a + candidate to a fact needs an explicit selection policy (HEURISTIC) or + evidence (EVIDENTIAL/NAL), never silence. +5. **PARK.** + - CG-3-style ordered REMOVE/SELECT: an effectful, order-dependent state + transition; it would need DT64 ordered effects plus an iteration + owner. + - `<<` / `**` scans (EXO-73). + - spaCy TIGER→UD crosswalk. + - Enhanced UD (non-tree). + - GF runtime. + +**Verdicts:** +- **PROVEN (lab):** + - the read-only role-candidate subset folds natively; + - surface case does not decide the role; + - word-order invariance by global id; + - UNKNOWN is kept apart; + - ambiguity is kept. +- **LAB:** `EqU32Via` as the head hop. +- **PROPOSED:** steps 1–4. +- **BLOCKED:** + - WHEN execution (`GuardNotExecutable`); + - 3VL execution; + - recursion (EXO-73). +- **NOT VERIFIED:** + - executable P1; + - scale beyond 40 tokens; + - Arabic; + - passive / null subject; + - CG-3 / spaCy run-time parity, since neither was executed. diff --git a/Cargo.toml b/Cargo.toml index 668599923..5671bfc59 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -141,6 +141,9 @@ exclude = [ # D-COGNITIVE-POLYFILL P0 lab probe; own workspace root. # `cargo test --manifest-path crates/cognitive-polyfill-probe/Cargo.toml`. "crates/cognitive-polyfill-probe", + # D-GRAMMAR-CONSTRAINTS-0 lab probe; own workspace root. + # `cargo test --manifest-path crates/grammar-constraints-probe/Cargo.toml`. + "crates/grammar-constraints-probe", "crates/symbiont", # OGAR (Open Graph of Active Record) activation crate — re-exports OGAR's full # AR surface (ogar-vocab Class/codebook + ogar-class-view impl ClassView + diff --git a/crates/grammar-constraints-probe/examples/cost.rs b/crates/grammar-constraints-probe/examples/cost.rs new file mode 100644 index 000000000..7debcb817 --- /dev/null +++ b/crates/grammar-constraints-probe/examples/cost.rs @@ -0,0 +1,53 @@ +//! Cold vs hot cost of the D-GRAMMAR-CONSTRAINTS-0 rules, reported separately. +//! cargo run --release --manifest-path crates/grammar-constraints-probe/Cargo.toml --example cost +//! Cold phases are one-off (reported, not gates); the hot run is the five +//! prepared programs over the 40-token corpus. + +use std::time::Instant; + +use grammar_constraints_probe::fixtures; +use grammar_constraints_probe::*; + +fn ns(f: impl FnOnce()) -> u128 { + let t = Instant::now(); + f(); + t.elapsed().as_nanos() +} + +fn main() { + let corpus = fixtures::corpus(); + let gids = fixtures::gids(&corpus); + let mut vocab = Vocab::new(); + let mut ids = None; + let t_vocab = ns(|| ids = Some(Ids::bind(&mut vocab))); + let ids = ids.unwrap(); + let mut tf = None; + let t_dr = ns(|| tf = Some(tile(7, &gids))); + let (table, frame) = tf.unwrap(); + let mut pop = None; + let t_pop = ns(|| pop = Some(Population::bind(&corpus, &mut vocab, &table, &frame).unwrap())); + let pop = pop.unwrap(); + let mut rules = None; + let t_rules = ns(|| rules = Some(Rules::prepare(&ids, Operands::ALL))); + let mut rules = rules.unwrap(); + + for _ in 0..10_000 { + std::hint::black_box(rules.run(&pop, &table, &frame).unwrap()); + } + let reps = 100_000u32; + let mut best = u128::MAX; + for _ in 0..5 { + let t = Instant::now(); + for _ in 0..reps { + std::hint::black_box( + rules + .run(std::hint::black_box(&pop), &table, &frame) + .unwrap(), + ); + } + best = best.min(t.elapsed().as_nanos() / u128::from(reps)); + } + println!("tokens={} programs=5 mask_ops={}", pop.n, rules.op_count()); + println!("cold (reported, not gates): vocab_bind={t_vocab} ns dr_map={t_dr} ns population_bind={t_pop} ns rule_prepare={t_rules} ns"); + println!("hot: five programs incl. frame re-check = {best} ns/run (best of 5 x {reps})"); +} diff --git a/crates/grammar-constraints-probe/src/lib.rs b/crates/grammar-constraints-probe/src/lib.rs index 006ddafe5..9d77f6dd6 100644 --- a/crates/grammar-constraints-probe/src/lib.rs +++ b/crates/grammar-constraints-probe/src/lib.rs @@ -260,12 +260,24 @@ fn pred(p: Pred, dst: u16) -> MaskOp { fn dative_arg(ids: &Ids, verbs: &[u32], ops: Operands) -> Program { let mut v = Vec::new(); v.push(if ops.deprel { - pred(Pred::EqU32 { lane: DEPREL, v: ids.obl_arg }, 0) + pred( + Pred::EqU32 { + lane: DEPREL, + v: ids.obl_arg, + }, + 0, + ) } else { pred(Pred::NeU32 { lane: DEPREL, v: 0 }, 0) }); v.push(if ops.case { - pred(Pred::EqU32 { lane: CASE, v: ids.dat }, 1) + pred( + Pred::EqU32 { + lane: CASE, + v: ids.dat, + }, + 1, + ) } else { pred(Pred::NeU32 { lane: DEPREL, v: 0 }, 1) }); @@ -313,8 +325,20 @@ fn dative_arg(ids: &Ids, verbs: &[u32], ops: Operands) -> Program { /// {Direction, Purpose}. fn zu_goal(ids: &Ids) -> Program { let v = vec![ - pred(Pred::EqU32 { lane: DEPREL, v: ids.case_rel }, 0), - pred(Pred::EqU32 { lane: LEMMA, v: ids.zu }, 1), + pred( + Pred::EqU32 { + lane: DEPREL, + v: ids.case_rel, + }, + 0, + ), + pred( + Pred::EqU32 { + lane: LEMMA, + v: ids.zu, + }, + 1, + ), MaskOp::And { a: Operand::Scratch(0), b: Operand::Scratch(1), @@ -362,8 +386,20 @@ fn zu_goal(ids: &Ids) -> Program { /// unknown case. Two programs. fn abstain_frame(ids: &Ids) -> Program { let mut v = vec![ - pred(Pred::EqU32 { lane: DEPREL, v: ids.obl_arg }, 0), - pred(Pred::EqU32 { lane: CASE, v: ids.dat }, 1), + pred( + Pred::EqU32 { + lane: DEPREL, + v: ids.obl_arg, + }, + 0, + ), + pred( + Pred::EqU32 { + lane: CASE, + v: ids.dat, + }, + 1, + ), MaskOp::And { a: Operand::Scratch(0), b: Operand::Scratch(1), @@ -386,7 +422,13 @@ fn abstain_frame(ids: &Ids) -> Program { fn unknown_case(ids: &Ids) -> Program { let mut v = vec![ - pred(Pred::EqU32 { lane: DEPREL, v: ids.obl_arg }, 0), + pred( + Pred::EqU32 { + lane: DEPREL, + v: ids.obl_arg, + }, + 0, + ), pred(Pred::EqU32 { lane: CASE, v: 0 }, 1), MaskOp::And { a: Operand::Scratch(0), @@ -410,7 +452,12 @@ fn unknown_case(ids: &Ids) -> Program { /// `dst = head.lemma ∈ give ∪ experience`, using `dst` and `dst + 1`. fn push_any_frame(v: &mut Vec, ids: &Ids, dst: u16) { - let verbs: Vec = ids.give.iter().chain(ids.experience.iter()).copied().collect(); + let verbs: Vec = ids + .give + .iter() + .chain(ids.experience.iter()) + .copied() + .collect(); for (i, &verb) in verbs.iter().enumerate() { let d = if i == 0 { dst } else { dst + 1 }; v.push(pred( @@ -512,7 +559,11 @@ impl Rules { (&self.unknown, &mut r.unknown_case), ] { let full = matches!(prog.terminal, Terminal::ScatterOrU32 { .. }); - let dst: &mut [u64] = if full { &mut out[..] } else { &mut out[..words] }; + let dst: &mut [u64] = if full { + &mut out[..] + } else { + &mut out[..words] + }; execute_into(prog, &planes, &foreign, &mut self.scratch, Out::Mask(dst)) .expect("probe program is valid"); } diff --git a/crates/grammar-constraints-probe/tests/p0.rs b/crates/grammar-constraints-probe/tests/p0.rs index f257721df..7ebc5ad67 100644 --- a/crates/grammar-constraints-probe/tests/p0.rs +++ b/crates/grammar-constraints-probe/tests/p0.rs @@ -174,7 +174,7 @@ fn surface_case_alone_does_not_determine_the_role() { // --- negative controls (brief §5) -------------------------------------------- -/// 1. GIVE → LIKE with the dative marking unchanged. +/// Control 1: GIVE → LIKE with the dative marking unchanged. #[test] fn c1_give_replaced_by_like() { let mut a = A; @@ -184,7 +184,7 @@ fn c1_give_replaced_by_like() { assert!(!k.recipient && k.experiencer, "{k:?}"); } -/// 2. The verb-frame constraint removed: predicted false positives in C and E. +/// Control 2: The verb-frame constraint removed: predicted false positives in C and E. #[test] fn c2_without_the_frame_constraint() { let corpus = fixtures::corpus(); @@ -201,7 +201,7 @@ fn c2_without_the_frame_constraint() { assert_eq!(s.false_pos, 4, "{s:?}"); // 105, 302, 405, 505 } -/// 3. Dependency direction swapped (Kind heads the verb). +/// Control 3: Dependency direction swapped (Kind heads the verb). #[test] fn c3_swapped_dependency_direction() { let mut a = A; @@ -214,7 +214,7 @@ fn c3_swapped_dependency_direction() { assert!(roled(&mut w, &a).is_empty()); } -/// 4. Only surface word order changes: "Das Buch gibt die Mutter dem Kind." +/// Control 4: Only surface word order changes: "Das Buch gibt die Mutter dem Kind." #[test] fn c4_word_order_changes_nothing() { let reordered = [A[5], A[6], A[2], A[0], A[1], A[3], A[4], A[7]]; @@ -229,7 +229,7 @@ fn c4_word_order_changes_nothing() { assert_ne!(l1, l2); } -/// 5. Ambiguous attachment: "zur Schule" under the verb (obl) or under +/// Control 5: ambiguous attachment: "zur Schule" under the verb (obl) or under /// "Buch" (nmod). Two readings, two populations; neither is merged away. #[test] fn c5_ambiguous_attachment_keeps_both_readings() { @@ -243,7 +243,7 @@ fn c5_ambiguous_attachment_keeps_both_readings() { assert!(!said(&mut w2, 208).dir_or_purp); } -/// 6. A supporting observation deleted: Kind's Case feature removed. +/// Control 6: A supporting observation deleted: Kind's Case feature removed. #[test] fn c6_missing_case_is_unknown_not_false() { let mut a = A; @@ -255,9 +255,14 @@ fn c6_missing_case_is_unknown_not_false() { let b: Vec = B.iter().copied().filter(|t| t.gid != 206).collect(); let mut wb = world(&b); assert!(!said(&mut wb, 208).dir_or_purp); + // and Schule without its Case feature is not asserted a goal either + let mut b2 = B; + b2[7].case = ""; + let mut wb2 = world(&b2); + assert!(!said(&mut wb2, 208).dir_or_purp); } -/// 7. A stale (foreign) coordinate frame is refused. +/// Control 7: A stale (foreign) coordinate frame is refused. #[test] fn c7_foreign_frame_refuses() { let mut w = world(&A); @@ -268,7 +273,7 @@ fn c7_foreign_frame_refuses() { )); } -/// 8. One required relational operand disabled: deprel. The dative +/// Control 8: one required relational operand disabled: deprel. The dative /// instrument `mit dem Löffel` (obl, Dat, under geben) becomes a recipient. #[test] fn c8_disabled_deprel_operand() { @@ -291,10 +296,7 @@ fn c8_disabled_deprel_operand() { #[test] fn when_guard_cannot_state_the_head_frame() { // The dependent's own conditions are a SetExpr over two lanes. - let own = SetExpr::And(vec![ - SetExpr::eq_u32(DEPREL, 1), - SetExpr::eq_u32(CASE, 2), - ]); + let own = SetExpr::And(vec![SetExpr::eq_u32(DEPREL, 1), SetExpr::eq_u32(CASE, 2)]); let recipient = Guard::new(own.clone(), Logic::TwoValued); let experiencer = Guard::new(own, Logic::TwoValued); // With only single-population leaves, the two rules have the SAME guard