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/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/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/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..9d77f6dd6 --- /dev/null +++ b/crates/grammar-constraints-probe/src/lib.rs @@ -0,0 +1,577 @@ +//! 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..7ebc5ad67 --- /dev/null +++ b/crates/grammar-constraints-probe/tests/p0.rs @@ -0,0 +1,331 @@ +//! 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) -------------------------------------------- + +/// Control 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:?}"); +} + +/// 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(); + 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 +} + +/// Control 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()); +} + +/// 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]]; + 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); +} + +/// 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() { + 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); +} + +/// Control 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); + // 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); +} + +/// Control 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(_))) + )); +} + +/// 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() { + 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); +}