Skip to main content

logicaffeine_language/parser/
noun.rs

1//! Noun phrase parsing with determiners, adjectives, and possessives.
2//!
3//! This module handles the full complexity of English noun phrases including:
4//!
5//! - **Determiners**: Articles (a, the), quantifiers (every, some, no)
6//! - **Adjectives**: Pre-nominal modifiers, intersective vs subsective
7//! - **Possessives**: "John's", "his", genitive constructions
8//! - **Proper names**: Capitalized constants
9//! - **Numeric literals**: Numbers as noun phrases for comparisons
10//! - **Prepositional phrases**: Post-nominal "of" constructions
11//! - **Superlatives**: "the tallest", "the most interesting"
12//!
13//! The parsed [`NounPhrase`] struct carries definiteness, adjectives, the head
14//! noun, optional possessor, and attached prepositional phrases.
15
16use super::clause::ClauseParsing;
17use super::pragmatics::PragmaticsParsing;
18use super::{ParseResult, Parser};
19use crate::ast::{LogicExpr, NounPhrase, Term};
20use crate::drs::{Case, Gender, Number};
21use logicaffeine_base::SymbolEq;
22use crate::lexicon::Definiteness;
23use crate::token::TokenType;
24use crate::transpile::capitalize_first;
25
26/// The preposition a declared CATEGORY contributes to a definite-description
27/// label, so the label un-fuses to the SAME relation the prepositional-phrase
28/// form produces. TEMPORAL ("the 2003 holiday" ↔ "in 2003") and LOCATIVE ("the
29/// Florida trip" ↔ "in Florida") both → `In`; PERSONAL ("the Bill trip" ↔ "with
30/// Bill") → `With`.
31///
32/// The lexicon SORT is consulted first where it is decisive (`Place`/`Time` →
33/// `In`, `Human` → `With`), but several calendar/region nouns carry an
34/// `Abstract` sort in the lexicon (year, month, week, decade, century, state),
35/// so an explicit lemma set backs the sort up. A category that maps to neither
36/// dimension (e.g. "vegetable") returns `None` and the label stays fused.
37fn category_preposition(category_lemma_lower: &str) -> Option<&'static str> {
38    const TEMPORAL: &[&str] = &["year", "month", "day", "date", "week", "decade", "century"];
39    const LOCATIVE: &[&str] =
40        &["state", "city", "country", "province", "region", "town", "place"];
41    const PERSONAL: &[&str] =
42        &["friend", "person", "man", "woman", "colleague", "companion"];
43
44    if TEMPORAL.contains(&category_lemma_lower) || LOCATIVE.contains(&category_lemma_lower) {
45        return Some("In");
46    }
47    if PERSONAL.contains(&category_lemma_lower) {
48        return Some("With");
49    }
50    match crate::lexicon::lookup_sort(category_lemma_lower) {
51        Some(crate::lexicon::Sort::Place) | Some(crate::lexicon::Sort::Time) => Some("In"),
52        Some(crate::lexicon::Sort::Human) => Some("With"),
53        _ => None,
54    }
55}
56
57/// Trait for parsing noun phrases.
58///
59/// Provides methods for parsing determiners, adjectives, possessives,
60/// and converting noun phrases to first-order terms.
61pub trait NounParsing<'a, 'ctx, 'int> {
62    /// Parses a full noun phrase with optional greedy PP attachment.
63    fn parse_noun_phrase(&mut self, greedy: bool) -> ParseResult<NounPhrase<'a>>;
64    /// Parses a noun phrase suitable for relative clause antecedent.
65    fn parse_noun_phrase_for_relative(&mut self) -> ParseResult<NounPhrase<'a>>;
66    /// Converts a parsed noun phrase to a first-order term.
67    fn noun_phrase_to_term(&self, np: &NounPhrase<'a>) -> Term<'a>;
68    /// Checks for possessive marker ('s).
69    fn check_possessive(&self) -> bool;
70    /// Checks for "of" preposition (possessive or partitive).
71    fn check_of_preposition(&self) -> bool;
72    /// Checks for proper name or label (capitalized).
73    fn check_proper_name_or_label(&self) -> bool;
74    /// Whether the cursor opens an object-gap reduced relative (subject + transitive
75    /// verb + empty object slot), e.g. "Tara won" in "the prize Tara won".
76    fn peek_reduced_object_relative(&self) -> bool;
77    /// Whether the cursor sits on a DEFINITE article that opens a noun phrase whose
78    /// head is modified by a reduced object relative ("the friend Simon went with",
79    /// "the waterfall Derrick photographed"). Used by the object-NP dispatcher to
80    /// route such an object through the full `parse_noun_phrase` machinery instead
81    /// of pre-consuming the article (which would hide the relative).
82    fn peek_definite_reduced_relative_object(&self) -> bool;
83    /// Checks for possessive pronoun (his, her, its, their).
84    fn check_possessive_pronoun(&self) -> bool;
85    /// Resolves a numeric LABEL ("the 2003 holiday", "the 1850 stamp"): returns
86    /// the head symbol, FUSED (`2003_holiday`) by default, but UN-FUSED to the
87    /// bare head plus a category relation restrictor (pushed onto
88    /// `measure_restrictors`) when `n` names a DRS-declared item whose category
89    /// maps to a preposition.
90    fn numeric_label_head(
91        &mut self,
92        n: i64,
93        head: crate::intern::Symbol,
94        definiteness: Option<Definiteness>,
95        measure_restrictors: &mut Vec<&'a LogicExpr<'a>>,
96    ) -> crate::intern::Symbol;
97    /// Consume a numeric-label HEAD preferring the NOUN reading of a verb-ambiguous
98    /// word, so the fused symbol matches the noun-compound form ("the 2001 trip" →
99    /// `2001_trip`, not the verb lemma `2001_Trip`). A verb-only head ("stamp")
100    /// has no noun reading and keeps its lemma; a plain noun ("holiday") is already
101    /// the noun. (The un-fused predicate is capitalized downstream regardless.)
102    fn consume_label_head_noun_first(&mut self) -> ParseResult<crate::intern::Symbol>;
103}
104
105impl<'a, 'ctx, 'int> NounParsing<'a, 'ctx, 'int> for Parser<'a, 'ctx, 'int> {
106    fn parse_noun_phrase(&mut self, greedy: bool) -> ParseResult<NounPhrase<'a>> {
107        let mut definiteness = None;
108        let mut adjectives = Vec::new();
109        let mut non_intersective_prefix: Option<crate::intern::Symbol> = None;
110        let mut possessor_from_pronoun: Option<&'a NounPhrase<'a>> = None;
111        let mut superlative_adj: Option<crate::intern::Symbol> = None;
112        // Attributive measure-adjective restrictors ("the 80 year old doll" →
113        // Old(_PP_SELF_, 80 years)); merged into the NP's pps after the head, so a
114        // degree property survives in every position the pps flow through.
115        let mut measure_restrictors: Vec<&'a LogicExpr<'a>> = Vec::new();
116
117        // Phase 35: Support numeric literals as noun phrases (e.g., "equal to 42").
118        // BUT only when the number stands alone — a number FOLLOWED by a content
119        // word heads a larger NP ("28 inch wingspan", "640 Twitter followers"),
120        // which the numeric-head compound logic below folds; early-returning here
121        // would strand the rest ("inch wingspan").
122        if let TokenType::Number(sym) = self.peek().kind {
123            let number_modifies_head = matches!(
124                self.tokens.get(self.current + 1).map(|t| &t.kind),
125                Some(TokenType::Noun(_)) | Some(TokenType::ProperName(_))
126                    | Some(TokenType::Adjective(_)) | Some(TokenType::Verb { .. })
127                    | Some(TokenType::Ambiguous { .. })
128            );
129            if !number_modifies_head {
130                self.advance();
131                return Ok(NounPhrase {
132                    definiteness: None,
133                    adjectives: &[],
134                    noun: sym,
135                    possessor: None,
136                    pps: &[],
137                    superlative: None,
138                });
139            }
140        }
141
142        if self.check_possessive_pronoun() {
143            let token = self.advance().clone();
144            let (gender, number) = match &token.kind {
145                TokenType::Pronoun { gender, number, case: Case::Possessive } => (*gender, *number),
146                TokenType::Ambiguous { primary, alternatives } => {
147                    let mut found = None;
148                    if let TokenType::Pronoun { gender, number, case: Case::Possessive } = **primary {
149                        found = Some((gender, number));
150                    }
151                    if found.is_none() {
152                        for alt in alternatives {
153                            if let TokenType::Pronoun { gender, number, case: Case::Possessive } = alt {
154                                found = Some((*gender, *number));
155                                break;
156                            }
157                        }
158                    }
159                    found.unwrap_or((Gender::Unknown, Number::Singular))
160                }
161                _ => (Gender::Unknown, Number::Singular),
162            };
163
164            let resolved = self.resolve_pronoun(gender, number)?;
165            let resolved_sym = match resolved {
166                super::ResolvedPronoun::Variable(s) | super::ResolvedPronoun::Constant(s) => s,
167            };
168
169            let possessor_np = NounPhrase {
170                definiteness: None,
171                adjectives: &[],
172                noun: resolved_sym,
173                possessor: None,
174                pps: &[],
175                superlative: None,
176            };
177            possessor_from_pronoun = Some(self.ctx.nps.alloc(possessor_np));
178            definiteness = Some(Definiteness::Definite);
179        } else if let TokenType::Article(def) = self.peek().kind {
180            // Phase 35: Disambiguate "a" as variable vs article
181            // If "a" or "an" is followed by a verb/copula/modal, it's a variable name, not an article
182            let is_variable_a = {
183                let lexeme = self.interner.resolve(self.peek().lexeme).to_lowercase();
184                if lexeme == "a" || lexeme == "an" {
185                    if let Some(next) = self.tokens.get(self.current + 1) {
186                        // A PROGRESSIVE verb (an "-ing" gerund) directly before a
187                        // noun head is a PRE-NOMINAL MODIFIER, not a predicate — so
188                        // "a kayaking REGIMEN", "a running SHOE" are indefinite NPs
189                        // and "a" is an article, not a logic variable. Only this
190                        // gerund+noun shape is excluded; "a runs"/"a sees Bob"/"a =
191                        // b" keep the variable reading.
192                        let gerund_premodifier = matches!(
193                            next.kind,
194                            TokenType::Verb { aspect: crate::lexicon::Aspect::Progressive, .. }
195                        ) && matches!(
196                            self.tokens.get(self.current + 2).map(|t| &t.kind),
197                            Some(TokenType::Noun(_)) | Some(TokenType::Ambiguous { .. })
198                        );
199                        !gerund_premodifier && matches!(next.kind,
200                            TokenType::Is | TokenType::Are | TokenType::Was | TokenType::Were | // Copula
201                            TokenType::Verb { .. } | // Main verb
202                            TokenType::Auxiliary(_) | // will, did
203                            TokenType::Must | TokenType::Can | TokenType::Should | TokenType::May | // Modals
204                            TokenType::Could | TokenType::Would | TokenType::Shall | TokenType::Might |
205                            TokenType::Identity | TokenType::Equals // "a = b"
206                        )
207                    } else {
208                        false
209                    }
210                } else {
211                    false
212                }
213            };
214
215            if !is_variable_a {
216                definiteness = Some(def);
217                self.advance();
218            }
219        }
220
221        if self.check_superlative() {
222            if let TokenType::Superlative(adj) = self.advance().kind {
223                superlative_adj = Some(adj);
224            }
225        }
226
227        if self.check_non_intersective_adjective() {
228            if let TokenType::NonIntersectiveAdjective(adj) = self.advance().kind {
229                non_intersective_prefix = Some(adj);
230            }
231        }
232
233        loop {
234            if self.is_at_end() {
235                break;
236            }
237
238            // A gerund (-ing form) directly before a noun head in a DEFINITE
239            // description is an attributive MODIFIER, not part of the head: "the
240            // hunting trip" → Trip(x) ∧ Hunt(x). Keeping the attribute as a
241            // first-class predicate (un-fused) means "hunting" is the same key
242            // wherever the entity is named ("the hunting vacation", "the hunting
243            // trip"), so the discourse layer can resolve them to one referent.
244            // Restricted to definites because reference-resolution applies to
245            // definite descriptions; a bare-plural object ("used bowling pins")
246            // becomes a constant with no restrictor to hold a separate predicate,
247            // so its modifier must stay folded into the head symbol (Bowl_pins).
248            if let TokenType::Verb {
249                lemma,
250                aspect: crate::lexicon::Aspect::Progressive,
251                ..
252            } = self.peek().kind
253            {
254                let next_is_head = matches!(
255                    self.tokens.get(self.current + 1).map(|t| &t.kind),
256                    Some(TokenType::Noun(_))
257                        | Some(TokenType::ProperName(_))
258                        | Some(TokenType::Ambiguous { .. })
259                );
260                if next_is_head && definiteness == Some(Definiteness::Definite) {
261                    self.advance();
262                    adjectives.push(lemma);
263                    continue;
264                }
265            }
266
267            // A proper name directly before a common-noun head in a DEFINITE
268            // description is an attributive LABEL modifier, not part of the head:
269            // "the Florida trip" → Trip(x) ∧ Florida(x); "the Woodard family" →
270            // Family(x) ∧ Woodard(x). Un-fusing keeps the label a first-class key
271            // so the discourse layer resolves "the Florida trip" and "the Florida
272            // vacation" (or "the Woodard family" and "the Woodard estate") to one
273            // referent. Definite-gated, so a bare named entity ("Lake Tahoe",
274            // "Leiman Manor") stays a single constant and never un-fuses.
275            if let TokenType::ProperName(label) = self.peek().kind {
276                let next_is_common_head = matches!(
277                    self.tokens.get(self.current + 1).map(|t| &t.kind),
278                    Some(TokenType::Noun(_)) | Some(TokenType::Ambiguous { .. })
279                );
280                if next_is_common_head && definiteness == Some(Definiteness::Definite) {
281                    self.advance();
282                    // A proper name that names a DECLARED item ("Florida"
283                    // registered as a state, "Bill" as a friend) un-fuses to a
284                    // P(_PP_SELF_, <name>) restrictor instead of a bare predicate,
285                    // converging with the prepositional-phrase form ("the trip was
286                    // in Florida" → In(x, Florida); "…with Bill" → With(x, Bill)).
287                    // The object term mirrors the PP form's constant EXACTLY so the
288                    // two unify. An undeclared label ("the Woodard family") keeps
289                    // its bare-predicate behavior.
290                    let unfused = self.drs.item_category(label).and_then(|category| {
291                        let cat_lemma = Self::singularize_noun(self.interner.resolve(category));
292                        category_preposition(&cat_lemma.to_lowercase())
293                    });
294                    if let Some(prep) = unfused {
295                        let prep_sym = self.interner.intern(prep);
296                        let placeholder = self.interner.intern("_PP_SELF_");
297                        let pred = self.ctx.exprs.alloc(LogicExpr::Predicate {
298                            name: prep_sym,
299                            args: self
300                                .ctx
301                                .terms
302                                .alloc_slice([Term::Variable(placeholder), Term::Constant(label)]),
303                            world: None,
304                        });
305                        measure_restrictors.push(pred);
306                    } else {
307                        adjectives.push(label);
308                    }
309                    continue;
310                }
311            }
312
313            // Compound color / quality term: a NOUN immediately before an
314            // ADJECTIVE that is in turn followed by a HEAD NOUN ("LIME green
315            // SHIRT", "MIDNIGHT blue CAR", "BLOOD red CAR") — the noun pre-modifies
316            // the adjective; fold the pair into one "Lime_green" adjective. The
317            // following head-noun requirement distinguishes this from a head noun
318            // + a post-nominal secondary predicate ("painted the DOOR red.", where
319            // "door" is the head and "red" a resultative): there no noun follows.
320            let color_compound = matches!(self.peek().kind, TokenType::Noun(_))
321                && matches!(
322                    self.tokens.get(self.current + 1).map(|t| &t.kind),
323                    Some(TokenType::Adjective(_))
324                )
325                && matches!(
326                    self.tokens.get(self.current + 2).map(|t| &t.kind),
327                    Some(TokenType::Noun(_))
328                        | Some(TokenType::ProperName(_))
329                        | Some(TokenType::Adjective(_))
330                        | Some(TokenType::Verb { .. })
331                        | Some(TokenType::Ambiguous { .. })
332                );
333            if color_compound {
334                let n = if let TokenType::Noun(n) = self.peek().kind { n } else { unreachable!() };
335                self.advance(); // the noun modifier
336                let a = if let TokenType::Adjective(a) = self.peek().kind { a } else { unreachable!() };
337                self.advance(); // the adjective
338                let compound = self.interner.intern(&format!(
339                    "{}_{}",
340                    self.interner.resolve(n),
341                    self.interner.resolve(a)
342                ));
343                adjectives.push(compound);
344                continue;
345            }
346
347            // Attributive measure-adjective: "80 year old [doll]", "28 inch long
348            // [wing]" — a measure phrase (Number + unit) modifying a following
349            // gradable ADJECTIVE is a degree property of the head, mirroring the
350            // predicative "is 80 years old" → Old(x, 80 years). Emit
351            // Adj(_PP_SELF_, value) so the degree AND its unit survive rather than
352            // stranding the unit token. Gated on the third token being an ADJECTIVE,
353            // so "28 inch wingspan" (a measure-noun compound) is left to the head.
354            let measure_num = match self.peek().kind {
355                TokenType::Cardinal(n) => Some(crate::ast::logic::NumberKind::Integer(n as i64)),
356                TokenType::Number(s) => {
357                    let raw = self.interner.resolve(s).replace(',', "");
358                    Some(
359                        raw.parse::<i64>()
360                            .map(crate::ast::logic::NumberKind::Integer)
361                            .unwrap_or(crate::ast::logic::NumberKind::Symbolic(s)),
362                    )
363                }
364                _ => None,
365            };
366            if let Some(kind) = measure_num {
367                let unit_tok = self.tokens.get(self.current + 1);
368                let unit_is_measure = unit_tok.map_or(false, |t| {
369                    matches!(t.kind, TokenType::CalendarUnit(_))
370                        || matches!(t.kind, TokenType::Noun(_)
371                            if crate::lexicon::lookup_unit_dimension(
372                                &self.interner.resolve(t.lexeme).to_lowercase()).is_some())
373                });
374                let adj_after = self.tokens.get(self.current + 2).and_then(|t| {
375                    if let TokenType::Adjective(a) = t.kind { Some(a) } else { None }
376                });
377                if unit_is_measure {
378                    if let Some(adj) = adj_after {
379                        let unit_sym = unit_tok.unwrap().lexeme;
380                        self.advance(); // number
381                        self.advance(); // unit
382                        self.advance(); // gradable adjective
383                        let placeholder = self.interner.intern("_PP_SELF_");
384                        let value = Term::Value { kind, unit: Some(unit_sym), dimension: None };
385                        let pred = self.ctx.exprs.alloc(LogicExpr::Predicate {
386                            name: adj,
387                            args: self
388                                .ctx
389                                .terms
390                                .alloc_slice([Term::Variable(placeholder), value]),
391                            world: None,
392                        });
393                        measure_restrictors.push(pred);
394                        continue;
395                    }
396                }
397            }
398
399            let is_adjective = matches!(self.peek().kind, TokenType::Adjective(_));
400            if !is_adjective {
401                break;
402            }
403
404            // A verb-only or ambiguous word after an adjective is the head noun
405            // ("the rare stamp", "the long run") — but ONLY inside a
406            // determiner-headed NP. Without the article gate, "studies hard
407            // pass …" would wrongly read the main verb "pass" as a noun ("hard"
408            // there is an adverb). The lexicon's is_adverb misses "hard", so the
409            // article is the reliable signal.
410            let next_is_content = if self.current + 1 < self.tokens.len() {
411                let next = &self.tokens[self.current + 1].kind;
412                matches!(
413                    next,
414                    TokenType::Noun(_) | TokenType::Adjective(_) | TokenType::ProperName(_)
415                ) || ((definiteness.is_some() || self.nominal_np_context)
416                    && matches!(next, TokenType::Verb { .. } | TokenType::Ambiguous { .. }))
417            } else {
418                false
419            };
420
421            if next_is_content {
422                if let TokenType::Adjective(adj) = self.advance().kind {
423                    adjectives.push(adj);
424                }
425            } else {
426                break;
427            }
428        }
429
430        // "the 8:15 pm event" / "the 9:30am outing" — a clock time names the head
431        // noun; compound the minutes-from-midnight into the symbol so the entity is
432        // identified by its time ("1215_event"), distinct from "570_outing".
433        let base_noun = if let TokenType::TimeLiteral { nanos_from_midnight } = self.peek().kind {
434            let minutes = nanos_from_midnight / 60_000_000_000;
435            let next_is_head = matches!(
436                self.tokens.get(self.current + 1).map(|t| &t.kind),
437                Some(TokenType::Noun(_)) | Some(TokenType::ProperName(_))
438                    | Some(TokenType::Adjective(_)) | Some(TokenType::Verb { .. })
439                    | Some(TokenType::Ambiguous { .. })
440            );
441            if next_is_head {
442                self.advance(); // consume the time literal
443                let head = self.consume_content_word()?;
444                let head_str = self.interner.resolve(head).to_string();
445                self.interner.intern(&format!("{}_{}", minutes, head_str))
446            } else {
447                self.consume_content_word()?
448            }
449        }
450        // "the 1848 home" / "the 1834 flood" — cardinal used as a year/label modifier
451        // before the head noun; compound the two into a single symbol.
452        else if let TokenType::Cardinal(n) = self.peek().kind {
453            // The head after a count/label cardinal can tokenize as a noun, a
454            // proper name, an adjective, or — for words that are also verbs
455            // ("dances", "places") — a verb / ambiguous token.
456            let next_is_content = self.tokens.get(self.current + 1)
457                .map_or(false, |t| matches!(
458                    t.kind,
459                    TokenType::Noun(_)
460                        | TokenType::ProperName(_)
461                        | TokenType::Adjective(_)
462                        | TokenType::Verb { .. }
463                        | TokenType::Ambiguous { .. }
464                        // "item"/"items" are nouns in declarative NL ("the $275
465                        // item"); the lexer keeps them as keyword tokens.
466                        | TokenType::Item
467                        | TokenType::Items
468                ));
469            if next_is_content {
470                self.advance(); // consume the cardinal
471                let head = self.consume_label_head_noun_first()?;
472                self.numeric_label_head(n as i64, head, definiteness, &mut measure_restrictors)
473            } else if n == 1 {
474                // "the one who paid $150" / "the one with 804 followers" / "the one
475                // from St. Paul" — "one" used PRONOMINALLY (no following head noun)
476                // is the impersonal pronoun, a generic entity, not the numeral 1.
477                self.advance(); // consume "one"
478                self.interner.intern("One")
479            } else {
480                self.consume_content_word()?
481            }
482        }
483        // "the 2003 holiday" / "the 1850 stamp" — a YEAR/numeric label lexes as a
484        // bare Number (digits), not a word-Cardinal, but plays the same role: it
485        // names the head noun. Route it through the SAME label logic so a declared
486        // item un-fuses to its category relation and an undeclared one stays fused.
487        else if let TokenType::Number(num_sym) = self.peek().kind {
488            let next_is_content = self.tokens.get(self.current + 1).map_or(false, |t| {
489                matches!(
490                    t.kind,
491                    TokenType::Noun(_)
492                        | TokenType::ProperName(_)
493                        | TokenType::Adjective(_)
494                        | TokenType::Verb { .. }
495                        | TokenType::Ambiguous { .. }
496                        | TokenType::Item
497                        | TokenType::Items
498                )
499            });
500            // Only an INTEGER label (a year / instance number) compounds with the
501            // head; a unit measure ("3.5 inch") is left to the measure path, and a
502            // non-integer Number never names an instance.
503            let int_value = self
504                .interner
505                .resolve(num_sym)
506                .replace(',', "")
507                .parse::<i64>()
508                .ok();
509            match (next_is_content, int_value) {
510                (true, Some(n)) => {
511                    self.advance(); // consume the number
512                    let head = self.consume_label_head_noun_first()?;
513                    self.numeric_label_head(n, head, definiteness, &mut measure_restrictors)
514                }
515                _ => self.consume_content_word()?,
516            }
517        } else {
518            self.consume_content_word()?
519        };
520
521        let noun = if let Some(prefix) = non_intersective_prefix {
522            let prefix_str = self.interner.resolve(prefix);
523            let base_str = self.interner.resolve(base_noun);
524            let compound = format!("{}-{}", prefix_str, base_str);
525            self.interner.intern(&compound)
526        } else {
527            base_noun
528        };
529
530        // Absorb EVERY consecutive proper-name label into the head ("Delta Gamma Pi" →
531        // Delta_Gamma_Pi, "Beta Pi Omega" → Beta_Pi_Omega) — a multi-word name can be
532        // 3+ words, not just two.
533        //   …but in a DETERMINER-headed NP ("the prize Tara won", "the waterfall
534        // Derrick photographed") a proper name that opens an object-gap reduced
535        // relative is the relative's SUBJECT, NOT a label compounded into the head —
536        // leave it for the reduced-relative detection below. A bare multi-word proper
537        // name ("Ray Ricardo won") has no determiner, and an apposition with an overt
538        // object ("the dancer Tara won the prize") has no gap, so both still compound.
539        let mut noun = noun;
540        while self.check_proper_name_or_label()
541            && !(definiteness.is_some() && self.peek_reduced_object_relative())
542        {
543            let label = self.consume_content_word()?;
544            noun = self.interner.intern(&format!(
545                "{}_{}",
546                self.interner.resolve(noun),
547                self.interner.resolve(label)
548            ));
549        }
550        let noun = noun;
551
552        // US "City, ST" address — a place proper name followed by ", " + a
553        // two-letter ALL-CAPS state abbreviation is ONE location entity
554        // ("Charlestown, CT" → Charlestown_CT, "Barnstable, ME" → Barnstable_ME).
555        // The all-caps gate excludes Title-case names ("Al", "Bo") and ordinary
556        // list/clause commas (names are never two-letter all-caps), so this never
557        // swallows a coordinator.
558        let noun = if self.check(&TokenType::Comma) {
559            let state_abbr = matches!(
560                self.tokens.get(self.current + 1).map(|t| &t.kind),
561                Some(TokenType::ProperName(s))
562                    if { let l = self.interner.resolve(*s); l.len() == 2 && l.chars().all(|c| c.is_ascii_uppercase()) }
563            );
564            let head_is_proper = self
565                .interner
566                .resolve(noun)
567                .chars()
568                .next()
569                .map_or(false, |c| c.is_ascii_uppercase());
570            if state_abbr && head_is_proper {
571                self.advance(); // consume the comma
572                let st = self.consume_content_word()?;
573                let compound = format!("{}_{}", self.interner.resolve(noun), self.interner.resolve(st));
574                self.interner.intern(&compound)
575            } else {
576                noun
577            }
578        } else {
579            noun
580        };
581
582        // Noun-noun compounds ("stop bit", "data bits", "grant signal"): a
583        // PLAIN noun directly after the head joins it as one compound head.
584        // An Ambiguous noun/verb word ("signal") joins ONLY when a copula
585        // follows it — there the verb reading is impossible — so genuine
586        // ambiguity ("Time flies like an arrow") keeps its verb reading and
587        // the forest enumerates the rest.
588        let mut noun = noun;
589        loop {
590            // The function word "as" lexes as a Noun (unknown-word fallback) but
591            // is never a compound-noun part — it introduces a predicative
592            // complement ("has Al Acosta AS its mayor"). Stop the compound here so
593            // the "as"-phrase is left for the verb path, not bundled into the head.
594            if matches!(self.peek().kind, TokenType::Noun(_))
595                && self.interner.resolve(self.peek().lexeme).eq_ignore_ascii_case("as")
596            {
597                break;
598            }
599            let next = match &self.peek().kind {
600                TokenType::Noun(next) => *next,
601                TokenType::Ambiguous { primary, alternatives } => {
602                    let noun_reading = if let TokenType::Noun(n) = &**primary {
603                        Some(*n)
604                    } else {
605                        alternatives.iter().find_map(|t| {
606                            if let TokenType::Noun(n) = t { Some(*n) } else { None }
607                        })
608                    };
609                    let copula_after = matches!(
610                        self.tokens.get(self.current + 1).map(|t| &t.kind),
611                        Some(TokenType::Is)
612                            | Some(TokenType::Are)
613                            | Some(TokenType::Was)
614                            | Some(TokenType::Were)
615                    );
616                    // Inside an OBJECT/complement NP (greedy == false the clause
617                    // already has its main verb) an ambiguous noun/verb word that
618                    // is NOT itself followed by its own argument cannot be a verb —
619                    // it joins the head as a compound ("has a glass head.", "holds
620                    // a paper clip.", "played second base WON"). The following
621                    // token confirms this: a clause boundary (Period/EOF/comma/
622                    // and/or) or a FINITE verb/copula (the matrix or next clause's
623                    // verb — "second base" then matrix "won"/"is") both rule out a
624                    // verb reading of the ambiguous word. A clause subject (greedy
625                    // == true) keeps the verb reading so "The man runs." is not
626                    // eaten; wh-extraction is excluded (the gap leaves the embedded
627                    // verb at a boundary, "…Mary said Bill saw?").
628                    let object_compound_boundary = !greedy
629                        && self.filler_gap.is_none()
630                        && matches!(
631                            self.tokens.get(self.current + 1).map(|t| &t.kind),
632                            None | Some(TokenType::Period)
633                                | Some(TokenType::EOF)
634                                | Some(TokenType::Comma)
635                                | Some(TokenType::And)
636                                | Some(TokenType::Or)
637                                | Some(TokenType::Verb { .. })
638                                | Some(TokenType::Is)
639                                | Some(TokenType::Are)
640                                | Some(TokenType::Was)
641                                | Some(TokenType::Were)
642                        );
643                    // A nominal context (copula complement / PP object /
644                    // comparative standard) likewise rules out the verb reading:
645                    // "is the Russell Road PROJECT", "the $5.25 PURCHASE".
646                    // After a numeric-MEASURE head ("60_gallon …"), the following
647                    // words are the measured head COMPOUND ("60 gallon FISH TANK"), so
648                    // an ambiguous noun/verb word ("fish") joins the head even in a
649                    // greedy subject — a measure premodifier cannot be followed by a
650                    // finite verb, so the ambiguity resolves to noun.
651                    let head_is_numeric_measure = self
652                        .interner
653                        .resolve(noun)
654                        .chars()
655                        .next()
656                        .map_or(false, |c| c.is_ascii_digit());
657                    match (
658                        noun_reading,
659                        copula_after
660                            || object_compound_boundary
661                            || self.nominal_np_context
662                            || head_is_numeric_measure,
663                    ) {
664                        (Some(n), true) => n,
665                        _ => break,
666                    }
667                }
668                // A verb-only word ("stamp", "print") after a numeric/label head
669                // is a compound-noun part ("the 125000 stamp"). A bare number
670                // cannot head an NP that acts, so a numeric head takes the
671                // following verb-word as its real head noun whatever follows —
672                // BUT only in BASE form (surface == lemma): "stamp"/"print" read
673                // as nouns, while an inflected form ("in 1850 sold …", "runs") is
674                // a genuine verb and must not be eaten. Otherwise we require a
675                // copula after so "the man runs fast" keeps "runs" as the verb.
676                TokenType::Verb { lemma, .. } => {
677                    let head_is_numeric = {
678                        // Accept decimal/grouped money amounts as numeric heads
679                        // ("$5.25 purchase" → 5.25, "$1,800 stamp" → 1,800) so the
680                        // deverbal-noun fold fires — a bare number can't head an
681                        // acting NP, so the following verb-word is its real head.
682                        let h = self.interner.resolve(noun);
683                        !h.is_empty()
684                            && h.chars().any(|c| c.is_ascii_digit())
685                            && h.chars().all(|c| c.is_ascii_digit() || c == '.' || c == ',')
686                    };
687                    let is_base_form = self
688                        .interner
689                        .resolve(self.peek().lexeme)
690                        .eq_ignore_ascii_case(self.interner.resolve(*lemma));
691                    let copula_after = matches!(
692                        self.tokens.get(self.current + 1).map(|t| &t.kind),
693                        Some(TokenType::Is)
694                            | Some(TokenType::Are)
695                            | Some(TokenType::Was)
696                            | Some(TokenType::Were)
697                    );
698                    // A base-form verb-word is a deverbal noun ONLY at the NP TAIL —
699                    // a clause boundary / finite verb / copula follows. If a VP
700                    // continuation follows instead (adverb, object, PP), it is the
701                    // MATRIX verb and must NOT be eaten: "the goods from Spain SELL
702                    // quickly" (a PP-object/nominal context, base "sell" + adverb)
703                    // keeps "sell" as the verb. This is the same NP-tail test the
704                    // object-boundary case below uses.
705                    let next_ends_np = matches!(
706                        self.tokens.get(self.current + 1).map(|t| &t.kind),
707                        None | Some(TokenType::Period)
708                            | Some(TokenType::EOF)
709                            | Some(TokenType::Comma)
710                            | Some(TokenType::And)
711                            | Some(TokenType::Or)
712                            | Some(TokenType::Verb { .. })
713                            | Some(TokenType::Is)
714                            | Some(TokenType::Are)
715                            | Some(TokenType::Was)
716                            | Some(TokenType::Were)
717                    );
718                    // A BASE-FORM verb-word after a noun head, inside a NOMINAL
719                    // context (a PP object / comparative standard) and at the NP tail,
720                    // is a deverbal noun-noun COMPOUND: "a cork COVER", "an amber BASE",
721                    // "a tataki ROLL". The base-form gate excludes inflected matrix
722                    // verbs (runs/votes/sold); the context gate keeps SUBJECT NPs out.
723                    let nominal_compound = self.nominal_np_context && is_base_form && next_ends_np;
724                    // A CAPITALIZED verb-word after an already MULTI-WORD proper-name
725                    // head ("Bald_Hill" + "Run") is the tail of a place name, not a
726                    // verb. The head MUST already be compounded (contains '_') — this
727                    // is what separates a place name from a SUBJECT + matrix verb
728                    // ("John" + idiom lemma "Die" → must stay John kicked-the-bucket,
729                    // not John_Die). Both sides capitalized + a clause boundary after.
730                    let proper_name_part = self.interner.resolve(noun).contains('_')
731                        && self.interner.resolve(noun).chars().next()
732                            .map_or(false, |c| c.is_ascii_uppercase())
733                        && self.interner.resolve(self.peek().lexeme).chars().next()
734                            .map_or(false, |c| c.is_ascii_uppercase())
735                        && matches!(
736                            self.tokens.get(self.current + 1).map(|t| &t.kind),
737                            None | Some(TokenType::Period) | Some(TokenType::EOF)
738                                | Some(TokenType::Comma) | Some(TokenType::And)
739                                | Some(TokenType::Or)
740                        );
741                    // A BASE-FORM verb-word in an OBJECT/complement NP (!greedy:
742                    // the clause already has its verb), NOT followed by its own
743                    // argument, is a deverbal noun joining the head ("the spicy
744                    // tataki ROLL", "the onion DIP"). The following token confirms
745                    // it: a clause boundary, or a finite verb/copula (the matrix or
746                    // next clause's verb) — both rule out a verb reading. A PRONOUN
747                    // after it ("own a donkey BEAT it") is NOT a boundary, so the
748                    // nuclear verb of a quantifier is left alone. Mirrors the
749                    // Ambiguous arm's object_compound_boundary.
750                    let object_boundary =
751                        !greedy && self.filler_gap.is_none() && is_base_form && next_ends_np;
752                    // A clause-final GERUND (-ing) after a noun head in an object /
753                    // nominal NP is a noun-incorporation compound ("started WEIGHT
754                    // LIFTING", "enjoys BIRD WATCHING") — the noun is the gerund's
755                    // incorporated object. Requires the NP tail (next_ends_np) so a
756                    // reduced relative with its own object ("the man lifting WEIGHTS")
757                    // is untouched. Uses the SURFACE form so the compound is
758                    // weight_lifting, not the lemma weight_lift.
759                    let gerund_compound = (!greedy || self.nominal_np_context)
760                        && self.filler_gap.is_none()
761                        && self
762                            .interner
763                            .resolve(self.peek().lexeme)
764                            .to_lowercase()
765                            .ends_with("ing")
766                        && next_ends_np;
767                    if (head_is_numeric && is_base_form) || copula_after || nominal_compound || proper_name_part || object_boundary {
768                        *lemma
769                    } else if gerund_compound {
770                        self.peek().lexeme
771                    } else {
772                        break;
773                    }
774                }
775                _ => break,
776            };
777            self.advance();
778            let head_str = self.interner.resolve(noun);
779            let next_str = self.interner.resolve(next);
780            let compound = format!("{}_{}", head_str, next_str);
781            noun = self.interner.intern(&compound);
782        }
783
784        // Head noun + numeric value: an instance/slot LABEL — "number 7",
785        // "room 204", "lane 3", "version 2", "exhibit 5", "car 7". This is a
786        // GENERAL grammatical pattern, not a hardcoded word list (which would
787        // never generalise to unseen clues): in English a common noun is
788        // otherwise never directly followed by a BARE number, so a head noun
789        // immediately followed by a Cardinal/Number names a specific instance —
790        // join them into one symbol. Both word-numbers ("seven") and digits ("7")
791        // apply. The one exception is a MEASURE ("a box 3 FEET tall"), where the
792        // number begins a unit phrase — a following CalendarUnit / registered unit
793        // word vetoes the label reading. Gated to declarative (NL) parsing so it
794        // never disturbs LOGOS imperative index syntax ("item 3 of arr").
795        let label_value: Option<String> = match self.peek().kind {
796            TokenType::Cardinal(n) => Some(n.to_string()),
797            TokenType::Number(sym) => Some(self.interner.resolve(sym).to_string()),
798            _ => None,
799        };
800        if let Some(value) = label_value {
801            let number_starts_measure = match self.tokens.get(self.current + 1) {
802                Some(t) => matches!(t.kind, TokenType::CalendarUnit(_))
803                    || crate::lexicon::lookup_unit_dimension(
804                        &self.interner.resolve(t.lexeme).to_lowercase(),
805                    )
806                    .is_some(),
807                None => false,
808            };
809            let head_str = self.interner.resolve(noun).to_string();
810            // A numeric head ("1850") is not a label base; only a real word is.
811            let head_is_word = !head_str.is_empty() && !head_str.chars().all(|c| c.is_ascii_digit());
812            // A MONTH head ("April 15") is a date, handled by the month+day rule
813            // just below — never an instance label.
814            let head_is_month = crate::lexicon::is_month(&head_str.to_lowercase());
815            if !number_starts_measure
816                && head_is_word
817                && !head_is_month
818                && self.mode == super::ParserMode::Declarative
819            {
820                self.advance();
821                noun = self.interner.intern(&format!("{}_{}", head_str, value));
822            }
823        }
824
825        // Month + day: "June 11", "May 3", "December 25" — the day (1–31) joins the
826        // month into a single date symbol ("June_11"). Month names are a closed
827        // lexical class (lexicon `months`).
828        {
829            let head_lower = self.interner.resolve(noun).to_lowercase();
830            if crate::lexicon::is_month(&head_lower) {
831                let day = match self.peek().kind {
832                    TokenType::Cardinal(n) if (1..=31).contains(&n) => Some(n),
833                    // A numeric day may carry an ordinal suffix ("15th", "3rd",
834                    // "1st", "2nd"); strip it so "April 15th" and "April 15" name
835                    // the same date ("April_15").
836                    TokenType::Number(s) => {
837                        let raw = self.interner.resolve(s);
838                        let digits = raw.trim_end_matches(|c: char| c.is_ascii_alphabetic());
839                        digits.parse::<u32>().ok().filter(|d| (1..=31).contains(d))
840                    }
841                    _ => None,
842                };
843                if let Some(day) = day {
844                    self.advance();
845                    let head_str = self.interner.resolve(noun);
846                    noun = self.interner.intern(&format!("{}_{}", head_str, day));
847                    // An attributive date modifies a following head noun ("the
848                    // April 15th birthday" → April_15_birthday); absorb it so the
849                    // real head isn't stranded.
850                    while let TokenType::Noun(next) = self.peek().kind {
851                        self.advance();
852                        noun = self.interner.intern(&format!(
853                            "{}_{}",
854                            self.interner.resolve(noun),
855                            self.interner.resolve(next)
856                        ));
857                    }
858                }
859            }
860        }
861
862        if self.check_possessive() {
863            self.advance();
864
865            let possessor = self.ctx.nps.alloc(NounPhrase {
866                definiteness,
867                adjectives: self.ctx.syms.alloc_slice(adjectives.clone()),
868                noun,
869                possessor: None,
870                pps: &[],
871                superlative: superlative_adj,
872            });
873
874            let mut possessed_noun = self.consume_content_word()?;
875            // A multi-word possessed compound noun ("Bernard's fountain pen",
876            // "Joe's yoga session") joins its consecutive nouns into one symbol,
877            // mirroring the head noun-noun compounding above.
878            while let TokenType::Noun(next) = self.peek().kind {
879                self.advance();
880                possessed_noun = self.interner.intern(&format!(
881                    "{}_{}",
882                    self.interner.resolve(possessed_noun),
883                    self.interner.resolve(next)
884                ));
885            }
886
887            return Ok(NounPhrase {
888                definiteness: None,
889                adjectives: &[],
890                noun: possessed_noun,
891                possessor: Some(possessor),
892                pps: &[],
893                superlative: None,
894            });
895        }
896
897        let should_attach_pps = greedy || self.pp_attach_to_noun;
898
899        let mut pps: Vec<&'a LogicExpr<'a>> = Vec::new();
900        // Attributive measure-adjectives ("80 year old") are core restrictors of the
901        // head — collected before it — so they attach unconditionally, ahead of any
902        // optional trailing PPs.
903        pps.extend(measure_restrictors.iter().copied());
904
905        // An "of <measure>" restrictor ("a maximum range OF 475 ft", "a book OF
906        // 500 pages") names the head noun's measured value. It is UNAMBIGUOUSLY a
907        // noun restrictor (never an event adjunct or a partitive), so attach it
908        // even in a non-greedy object NP — otherwise "has a maximum range of 475
909        // ft" silently strands the measure.
910        loop {
911            let of_measure = self.check_of_preposition()
912                && matches!(
913                    self.tokens.get(self.current + 1).map(|t| &t.kind),
914                    Some(TokenType::Number(_)) | Some(TokenType::Cardinal(_))
915                );
916            if !of_measure {
917                break;
918            }
919            let of_sym = match self.advance().kind {
920                TokenType::Preposition(s) => s,
921                _ => break,
922            };
923            let placeholder_var = self.interner.intern("_PP_SELF_");
924            let measure = self.parse_measure_phrase()?;
925            pps.push(self.ctx.exprs.alloc(LogicExpr::Predicate {
926                name: of_sym,
927                args: self
928                    .ctx
929                    .terms
930                    .alloc_slice([Term::Variable(placeholder_var), *measure]),
931                world: None,
932            }));
933        }
934
935        // Postposed measure-adjective "worth <measure>" ("the magnate WORTH $27
936        // billion", "a stamp WORTH $50") — a postnominal adjective taking a measure
937        // complement, the same Worth(x, measure) the copula complement builds. Without
938        // this "worth" strands (TrailingTokens{Adjective}). Surface it as a restrictor
939        // over the _PP_SELF_ placeholder so it lowers onto the NP's entity.
940        if matches!(self.peek().kind, TokenType::Adjective(_))
941            && self
942                .interner
943                .resolve(self.peek().lexeme)
944                .eq_ignore_ascii_case("worth")
945            && matches!(
946                self.tokens.get(self.current + 1).map(|t| &t.kind),
947                Some(TokenType::Number(_)) | Some(TokenType::Cardinal(_))
948            )
949        {
950            let worth_sym = if let TokenType::Adjective(s) = self.peek().kind {
951                s
952            } else {
953                unreachable!()
954            };
955            self.advance(); // "worth"
956            let measure = self.parse_measure_phrase()?;
957            let placeholder = self.interner.intern("_PP_SELF_");
958            // Push directly to `pps` — the measure_restrictors → pps merge already ran
959            // above (this is the post-head section).
960            pps.push(self.ctx.exprs.alloc(LogicExpr::Predicate {
961                name: worth_sym,
962                args: self
963                    .ctx
964                    .terms
965                    .alloc_slice([Term::Variable(placeholder), *measure]),
966                world: None,
967            }));
968        }
969
970        if should_attach_pps {
971            // "of" normally starts a partitive/genitive handled elsewhere, so it
972            // is excluded here — EXCEPT "of <measure>" ("a range of 650 ft", "a
973            // book of 500 pages"), where the of-phrase specifies the head noun's
974            // measured value. That is a genuine restrictor, not a partitive, so
975            // attach it as an Of(self, <measure>) PP.
976            let of_measure_follows = |p: &Self| {
977                p.check_of_preposition()
978                    && matches!(
979                        p.tokens.get(p.current + 1).map(|t| &t.kind),
980                        Some(TokenType::Number(_)) | Some(TokenType::Cardinal(_))
981                    )
982            };
983            while self.check_preposition() && (!self.check_of_preposition() || of_measure_follows(self)) {
984                let prep_token = self.advance().clone();
985                let prep_name = if let TokenType::Preposition(sym) = prep_token.kind {
986                    sym
987                } else {
988                    break;
989                };
990
991                let placeholder_var = self.interner.intern("_PP_SELF_");
992                if self.check_number()
993                    && !matches!(
994                        self.tokens.get(self.current + 2).map(|t| &t.kind),
995                        Some(TokenType::Noun(_)) | Some(TokenType::Ambiguous { .. })
996                    )
997                {
998                    // Numeric PP object ("with 15 people", "at 385 degrees"):
999                    // keep the measured amount as the PP's object. A noun/ambiguous
1000                    // head after the unit ("with 205 degree WATER") is instead a
1001                    // measure-premodified noun, folded by the NP branch below.
1002                    let measure = self.parse_measure_phrase()?;
1003                    let pp_pred = self.ctx.exprs.alloc(LogicExpr::Predicate {
1004                        name: prep_name,
1005                        args: self
1006                            .ctx
1007                            .terms
1008                            .alloc_slice([Term::Variable(placeholder_var), *measure]),
1009                        world: None,
1010                    });
1011                    pps.push(pp_pred);
1012                } else if self.check_content_word()
1013                    || self.check_number()
1014                    || matches!(self.peek().kind, TokenType::Article(_))
1015                {
1016                    // A PP object is an unambiguously NOMINAL tail, so a base-form
1017                    // verb-word after its noun head is a deverbal compound ("with a
1018                    // cork COVER", "with a faux leather COVER"), not a matrix verb.
1019                    let saved_ctx = self.nominal_np_context;
1020                    self.nominal_np_context = true;
1021                    let pp_object_result = self.parse_noun_phrase(true);
1022                    self.nominal_np_context = saved_ctx;
1023                    let pp_object = pp_object_result?;
1024                    let pp_pred = self.ctx.exprs.alloc(LogicExpr::Predicate {
1025                        name: prep_name,
1026                        args: self.ctx.terms.alloc_slice([
1027                            Term::Variable(placeholder_var),
1028                            Term::Constant(pp_object.noun),
1029                        ]),
1030                        world: None,
1031                    });
1032                    pps.push(pp_pred);
1033                    // The PP object's own ADJECTIVES ("with the BLUE hat" →
1034                    // With(self, Hat) ∧ Blue(Hat)) and nested restrictors ("with a
1035                    // maximum range of 650 ft" → … ∧ Of(Range, 650 ft)) survive via
1036                    // the shared recovery (the single source of truth for every PP
1037                    // position); dropping them would be meaning loss.
1038                    pps.extend(self.pp_object_modifier_preds(&pp_object));
1039                } else {
1040                    break;
1041                }
1042            }
1043
1044            // Active object-gap reduced relative ("the waterfall Derrick photographed
1045            // in 1989" = the waterfall [that] Derrick photographed): a determiner-
1046            // headed NP whose head is followed by a reduced-relative subject+verb with
1047            // an EMPTY object slot (the gap is THIS head; English drops the
1048            // relativizer). The empty-object + transitive test (peek_reduced_object_relative)
1049            // is the PROPER deterministic discriminator — an overt object is apposition,
1050            // an intransitive verb has no gap. Reuse parse_relative_clause with the
1051            // `_PP_SELF_` placeholder so the clause — and its event complements ("in
1052            // 1989") — attach as a restrictor wherever the NP flows.
1053            if definiteness.is_some() && self.peek_reduced_object_relative() {
1054                let placeholder = self.interner.intern("_PP_SELF_");
1055                let rel = self.parse_relative_clause(placeholder)?;
1056                pps.push(rel);
1057            }
1058
1059            // Post-nominal "-ing" reduced relative ("the person arriving at
1060            // Paradise", "the assignment beginning in June", "the conductor
1061            // working on June 11"): a present participle after the noun is
1062            // unambiguously a reduced relative — it cannot be a finite main verb
1063            // without an auxiliary — so it restricts THIS noun phrase wherever the
1064            // NP appears (subject, standard, of-pair member, predicate nominal).
1065            // Attach as predicates over the `_PP_SELF_` placeholder (substituted
1066            // to the NP's variable when the NP is wrapped).
1067            if let TokenType::Verb { lemma, .. } = self.peek().kind {
1068                let is_ing = self
1069                    .interner
1070                    .resolve(self.peek().lexeme)
1071                    .to_lowercase()
1072                    .ends_with("ing");
1073                // A past participle followed by a "by"-agent is an unambiguous
1074                // PASSIVE reduced relative ("the bird trained BY the falconer",
1075                // "the photo published BY Wildzone") — a finite main verb is not
1076                // followed by a by-phrase in this position.
1077                let passive_by = matches!(
1078                    self.tokens.get(self.current + 1).map(|t| &t.kind),
1079                    Some(TokenType::Preposition(s))
1080                        if self.interner.resolve(*s).eq_ignore_ascii_case("by")
1081                );
1082                // A past participle of a TRANSITIVE verb immediately followed by a
1083                // PP (no object) is a passive reduced relative ("the medicine
1084                // sourced FROM a fig", "the item made OF teak", "the flower grown
1085                // IN Olin"). Lexical transitivity disambiguates it from an
1086                // intransitive main clause ("the box arrived in April"), which a
1087                // bare common noun + PP would otherwise look like.
1088                //
1089                // The near-dead `is_transitive_verb` table (47/2623 verbs) misses
1090                // most transitives ("the gator CAUGHT in Lynn" — catch has past ==
1091                // participle, so the distinct-form rule can't save it either). In a
1092                // NOMINAL complement position (`nominal_np_context`: "X is the gator
1093                // caught in Lynn") the NP cannot be a main-clause subject, so a
1094                // past-participle + PP IS a reduced relative for any verb that is not
1095                // marked pure-intransitive — the same transitive-capable-by-default
1096                // rule the object-gap relative uses. In subject position the strict
1097                // table still gates it (keeping "The team won in 1989" a main clause).
1098                let transitive_passive = matches!(
1099                    self.peek().kind,
1100                    TokenType::Verb { time: crate::lexicon::Time::Past, .. }
1101                ) && matches!(
1102                    self.tokens.get(self.current + 1).map(|t| &t.kind),
1103                    Some(TokenType::Preposition(_))
1104                ) && (crate::lexicon::is_transitive_verb(
1105                    &self.interner.resolve(lemma).to_lowercase(),
1106                ) || (self.nominal_np_context
1107                    && !crate::lexicon::is_intransitive_verb(
1108                        &self.interner.resolve(lemma).to_lowercase(),
1109                    )));
1110                // A DISTINCT past-participle form (participle ≠ past: "grown" vs
1111                // "grew", "taken" vs "took") immediately followed by a PP is an
1112                // unambiguous passive reduced relative regardless of transitivity —
1113                // the form alone is non-finite, like "-ing" ("the flower GROWN in
1114                // Hardy", "the package TAKEN to the depot").
1115                let distinct_participle_passive = matches!(
1116                    self.tokens.get(self.current + 1).map(|t| &t.kind),
1117                    Some(TokenType::Preposition(_))
1118                ) && crate::lexicon::is_distinct_past_participle(
1119                    &self.interner.resolve(self.peek().lexeme).to_lowercase(),
1120                );
1121                if is_ing || passive_by || transitive_passive || distinct_participle_passive {
1122                    self.advance();
1123                    let placeholder = self.interner.intern("_PP_SELF_");
1124                    // An ACTIVE -ing reduced relative can take a DIRECT OBJECT
1125                    // ("the origami DEPICTING a dragon", "the survivor BRINGING the
1126                    // rope") → Participle(x, object). Only -ing (a passive
1127                    // participle's patient is the head itself), and only a real
1128                    // object NP (article / content word), never a preposition or
1129                    // the matrix verb.
1130                    let direct_obj = if is_ing
1131                        && (matches!(self.peek().kind, TokenType::Article(_))
1132                            || self.check_content_word())
1133                        && !self.check_preposition()
1134                        && !self.check_verb()
1135                    {
1136                        Some(self.parse_noun_phrase(true)?)
1137                    } else {
1138                        None
1139                    };
1140                    let participle_pred = if let Some(ref obj) = direct_obj {
1141                        self.ctx.exprs.alloc(LogicExpr::Predicate {
1142                            name: lemma,
1143                            args: self.ctx.terms.alloc_slice([
1144                                Term::Variable(placeholder),
1145                                Term::Constant(obj.noun),
1146                            ]),
1147                            world: None,
1148                        })
1149                    } else {
1150                        self.ctx.exprs.alloc(LogicExpr::Predicate {
1151                            name: lemma,
1152                            args: self.ctx.terms.alloc_slice([Term::Variable(placeholder)]),
1153                            world: None,
1154                        })
1155                    };
1156                    pps.push(participle_pred);
1157                    // The participle's PP / directional-"to" complements. "of" is
1158                    // allowed here ("made OF teak", "made OF sandalwood") — it is
1159                    // the participle's material complement, not a possessive.
1160                    loop {
1161                        let prep = if self.check_preposition() {
1162                            match self.advance().kind {
1163                                TokenType::Preposition(s) => s,
1164                                _ => break,
1165                            }
1166                        } else if self.check(&TokenType::To)
1167                            && matches!(
1168                                self.tokens.get(self.current + 1).map(|t| &t.kind),
1169                                Some(TokenType::Article(_))
1170                                    | Some(TokenType::Noun(_))
1171                                    | Some(TokenType::ProperName(_))
1172                            )
1173                        {
1174                            self.advance();
1175                            self.interner.intern("To")
1176                        } else {
1177                            break;
1178                        };
1179                        if self.check_number() {
1180                            // A numeric PP object in a reduced relative: "found IN
1181                            // 1992", "sent in 1976", "donated in 2010" — keep the
1182                            // year/amount, else it strands.
1183                            let measure = self.parse_measure_phrase()?;
1184                            pps.push(self.ctx.exprs.alloc(LogicExpr::Predicate {
1185                                name: prep,
1186                                args: self.ctx.terms.alloc_slice([
1187                                    Term::Variable(placeholder),
1188                                    *measure,
1189                                ]),
1190                                world: None,
1191                            }));
1192                        } else if self.check_content_word()
1193                            || matches!(self.peek().kind, TokenType::Article(_))
1194                        {
1195                            let obj = self.parse_noun_phrase(true)?;
1196                            pps.push(self.ctx.exprs.alloc(LogicExpr::Predicate {
1197                                name: prep,
1198                                args: self.ctx.terms.alloc_slice([
1199                                    Term::Variable(placeholder),
1200                                    Term::Constant(obj.noun),
1201                                ]),
1202                                world: None,
1203                            }));
1204                        } else {
1205                            break;
1206                        }
1207                    }
1208                }
1209            }
1210        }
1211        let pps_slice = self.ctx.pps.alloc_slice(pps);
1212
1213        if self.check_of_preposition() {
1214            // Two-Pass Type Disambiguation:
1215            // If the noun is a known generic type (e.g., "Stack", "List"),
1216            // then "X of Y" is a type instantiation, not a possessive.
1217            // For now, we still parse it as possessive structurally, but
1218            // the type_registry enables future AST extensions for type annotations.
1219            let is_generic = self.is_generic_type(noun);
1220
1221            if !is_generic {
1222                // Standard possessive: "owner of house" → possessor relationship
1223                self.advance();
1224
1225                let possessor_np = self.parse_noun_phrase(true)?;
1226                let possessor = self.ctx.nps.alloc(possessor_np);
1227
1228                return Ok(NounPhrase {
1229                    definiteness,
1230                    adjectives: self.ctx.syms.alloc_slice(adjectives),
1231                    noun,
1232                    possessor: Some(possessor),
1233                    pps: pps_slice,
1234                    superlative: superlative_adj,
1235                });
1236            }
1237            // If generic type, fall through to regular noun phrase handling.
1238            // The "of [Type]" will be left unparsed for now.
1239            // Future: Parse as GenericType { base: noun, params: [...] }
1240        }
1241
1242        // Register ALL noun phrases as discourse entities, not just definite ones.
1243        // This is needed for bridging anaphora: "I bought a car. The engine smoked."
1244        // The indefinite "a car" must be in discourse history for "the engine" to link to it.
1245        let noun_str = self.interner.resolve(noun);
1246        let first_char = noun_str.chars().next().unwrap_or('X');
1247        if first_char.is_alphabetic() {
1248            // Use full noun name as symbol for consistent output in Full mode
1249            let symbol = capitalize_first(noun_str);
1250            let number = if noun_str.ends_with('s') && !noun_str.ends_with("ss") {
1251                Number::Plural
1252            } else {
1253                Number::Singular
1254            };
1255        }
1256
1257        Ok(NounPhrase {
1258            definiteness,
1259            adjectives: self.ctx.syms.alloc_slice(adjectives),
1260            noun,
1261            possessor: possessor_from_pronoun,
1262            pps: pps_slice,
1263            superlative: superlative_adj,
1264        })
1265    }
1266
1267    fn parse_noun_phrase_for_relative(&mut self) -> ParseResult<NounPhrase<'a>> {
1268        let mut definiteness = None;
1269        let mut adjectives = Vec::new();
1270
1271        if let TokenType::Article(def) = self.peek().kind {
1272            definiteness = Some(def);
1273            self.advance();
1274        }
1275
1276        loop {
1277            if self.is_at_end() {
1278                break;
1279            }
1280
1281            let is_adjective = matches!(self.peek().kind, TokenType::Adjective(_));
1282            if !is_adjective {
1283                break;
1284            }
1285
1286            let next_is_content = if self.current + 1 < self.tokens.len() {
1287                matches!(
1288                    self.tokens[self.current + 1].kind,
1289                    TokenType::Noun(_)
1290                        | TokenType::Adjective(_)
1291                        | TokenType::Verb { .. }
1292                        | TokenType::ProperName(_)
1293                )
1294            } else {
1295                false
1296            };
1297
1298            if next_is_content {
1299                if let TokenType::Adjective(adj) = self.advance().kind.clone() {
1300                    adjectives.push(adj);
1301                }
1302            } else {
1303                break;
1304            }
1305        }
1306
1307        let noun = self.consume_content_word_for_relative()?;
1308
1309        if self.check(&TokenType::That) || self.check(&TokenType::Who) {
1310            self.advance();
1311            let var_name = self.interner.intern(&format!("r{}", self.var_counter));
1312            self.var_counter += 1;
1313            let _nested_clause = self.parse_relative_clause(var_name)?;
1314        }
1315
1316        Ok(NounPhrase {
1317            definiteness,
1318            adjectives: self.ctx.syms.alloc_slice(adjectives),
1319            noun,
1320            possessor: None,
1321            pps: &[],
1322            superlative: None,
1323        })
1324    }
1325
1326    fn noun_phrase_to_term(&self, np: &NounPhrase<'a>) -> Term<'a> {
1327        if let Some(possessor) = np.possessor {
1328            let possessor_term = self.noun_phrase_to_term(possessor);
1329            Term::Possessed {
1330                possessor: self.ctx.terms.alloc(possessor_term),
1331                possessed: np.noun,
1332            }
1333        } else {
1334            Term::Constant(np.noun)
1335        }
1336    }
1337
1338    fn numeric_label_head(
1339        &mut self,
1340        n: i64,
1341        head: crate::intern::Symbol,
1342        definiteness: Option<Definiteness>,
1343        measure_restrictors: &mut Vec<&'a LogicExpr<'a>>,
1344    ) -> crate::intern::Symbol {
1345        // A numeric label that names a DECLARED item ("2003" registered as a
1346        // year) un-fuses: the label becomes the head noun PLUS a
1347        // P(_PP_SELF_, <number>) restrictor, converging with the
1348        // prepositional-phrase form ("the holiday was in 2003" →
1349        // Holiday(x) ∧ In(x, 2003)). The numeric term mirrors the PP form's
1350        // measure value EXACTLY so the two unify. Un-fusing is gated to definite
1351        // descriptions (a label refers); an undeclared number — or a category
1352        // that maps to no preposition — keeps the fused symbol.
1353        if definiteness == Some(Definiteness::Definite) {
1354            let item_sym = self.interner.intern(&n.to_string());
1355            if let Some(category) = self.drs.item_category(item_sym) {
1356                let cat_lemma = Self::singularize_noun(self.interner.resolve(category));
1357                if let Some(prep) = category_preposition(&cat_lemma.to_lowercase()) {
1358                    let prep_sym = self.interner.intern(prep);
1359                    let placeholder = self.interner.intern("_PP_SELF_");
1360                    let value = Term::Value {
1361                        kind: crate::ast::logic::NumberKind::Integer(n),
1362                        unit: None,
1363                        dimension: None,
1364                    };
1365                    let pred = self.ctx.exprs.alloc(LogicExpr::Predicate {
1366                        name: prep_sym,
1367                        args: self
1368                            .ctx
1369                            .terms
1370                            .alloc_slice([Term::Variable(placeholder), value]),
1371                        world: None,
1372                    });
1373                    measure_restrictors.push(pred);
1374                    return head;
1375                }
1376            }
1377        }
1378        let head_str = self.interner.resolve(head);
1379        self.interner.intern(&format!("{}_{}", n, head_str))
1380    }
1381
1382    fn consume_label_head_noun_first(&mut self) -> ParseResult<crate::intern::Symbol> {
1383        let noun_sym = if let TokenType::Ambiguous { primary, alternatives } = &self.peek().kind {
1384            let from_primary = match **primary {
1385                TokenType::Noun(s) | TokenType::Adjective(s) => Some(s),
1386                _ => None,
1387            };
1388            from_primary.or_else(|| {
1389                alternatives.iter().find_map(|t| match t {
1390                    TokenType::Noun(s) | TokenType::Adjective(s) => Some(*s),
1391                    _ => None,
1392                })
1393            })
1394        } else {
1395            None
1396        };
1397        if let Some(s) = noun_sym {
1398            self.advance();
1399            return Ok(s);
1400        }
1401        self.consume_content_word()
1402    }
1403
1404    fn check_possessive(&self) -> bool {
1405        matches!(self.peek().kind, TokenType::Possessive)
1406    }
1407
1408    /// Whether the cursor is at the SUBJECT of an active object-gap reduced relative
1409    /// ("the prize | Tara won", cursor at "Tara"). The PROPER, deterministic test
1410    /// (no trial-parse): a proper-name / pronoun subject, then a TRANSITIVE verb
1411    /// (only a transitive verb has an object slot for the head to fill — an
1412    /// intransitive "the dancer Tara performed" is apposition, not a relative), then
1413    /// an EMPTY object slot — the token after the verb does NOT start a direct object
1414    /// (an overt object "the dancer Tara won THE PRIZE" is apposition, not a gap).
1415    /// The caller additionally requires a determiner-headed NP.
1416    fn peek_reduced_object_relative(&self) -> bool {
1417        if !matches!(self.peek().kind, TokenType::ProperName(_) | TokenType::Pronoun { .. }) {
1418            return false;
1419        }
1420        // A token that would START a direct/prepositional object — its presence after
1421        // the verb means the slot is filled (apposition), so there is no gap.
1422        let starts_object = |kind: Option<&TokenType>| {
1423            matches!(
1424                kind,
1425                Some(TokenType::Article(_))
1426                    | Some(TokenType::Noun(_))
1427                    | Some(TokenType::ProperName(_))
1428                    | Some(TokenType::Number(_))
1429                    | Some(TokenType::Cardinal(_))
1430                    | Some(TokenType::Possessive)
1431                    | Some(TokenType::Pronoun { .. })
1432                    | Some(TokenType::All)
1433                    | Some(TokenType::Some)
1434                    | Some(TokenType::No)
1435                    | Some(TokenType::Any)
1436                    | Some(TokenType::Most)
1437                    | Some(TokenType::Few)
1438                    | Some(TokenType::Many)
1439            )
1440        };
1441        // Assume transitive-CAPABLE by default (English verbs overwhelmingly are);
1442        // only a verb marked pure-intransitive has no DIRECT object slot to fill.
1443        let verb_is_intransitive = self.tokens.get(self.current + 1).map_or(true, |t| {
1444            matches!(t.kind, TokenType::Verb { lemma, .. }
1445                if crate::lexicon::is_intransitive_verb(
1446                    &self.interner.resolve(lemma).to_lowercase()))
1447        });
1448        let verb_follows = matches!(
1449            self.tokens.get(self.current + 1).map(|t| &t.kind),
1450            Some(TokenType::Verb { .. })
1451        );
1452        if !verb_follows {
1453            return false;
1454        }
1455        // A STRANDED preposition after the verb ("the friend Simon went WITH", "the
1456        // animal Eva works WITH") makes the GAP the object of that preposition, so
1457        // even a pure-intransitive verb heads the relative. The preposition's own
1458        // object slot must be empty (the next token does not start an NP).
1459        let stranded_prep = matches!(
1460            self.tokens.get(self.current + 2).map(|t| &t.kind),
1461            Some(TokenType::Preposition(_))
1462        ) && !starts_object(self.tokens.get(self.current + 3).map(|t| &t.kind));
1463        if stranded_prep {
1464            return true;
1465        }
1466        // Otherwise a transitive verb with an EMPTY direct-object slot (the head is
1467        // the gap); an overt object after the verb is apposition, not a gap.
1468        if verb_is_intransitive {
1469            return false;
1470        }
1471        !starts_object(self.tokens.get(self.current + 2).map(|t| &t.kind))
1472    }
1473
1474    fn peek_definite_reduced_relative_object(&self) -> bool {
1475        // The cursor must open a DEFINITE NP.
1476        if !matches!(self.peek().kind, TokenType::Article(Definiteness::Definite)) {
1477            return false;
1478        }
1479        // Skip the article, then any adjectives, to land on the noun head.
1480        let mut p = self.current + 1;
1481        while matches!(
1482            self.tokens.get(p).map(|t| &t.kind),
1483            Some(TokenType::Adjective(_)) | Some(TokenType::NonIntersectiveAdjective(_))
1484        ) {
1485            p += 1;
1486        }
1487        // The relativized head: a common noun.
1488        if !matches!(
1489            self.tokens.get(p).map(|t| &t.kind),
1490            Some(TokenType::Noun(_))
1491                | Some(TokenType::CalendarUnit(_))
1492                | Some(TokenType::Ambiguous { .. })
1493        ) {
1494            return false;
1495        }
1496        let subj = p + 1;
1497        // The relative's overt subject is a fresh ProperName / Pronoun.
1498        if !matches!(
1499            self.tokens.get(subj).map(|t| &t.kind),
1500            Some(TokenType::ProperName(_)) | Some(TokenType::Pronoun { .. })
1501        ) {
1502            return false;
1503        }
1504        // …immediately followed by the relative's finite verb.
1505        let vp = subj + 1;
1506        if !matches!(
1507            self.tokens.get(vp).map(|t| &t.kind),
1508            Some(TokenType::Verb { .. })
1509        ) {
1510            return false;
1511        }
1512        // Either a stranded preposition (gap = its object: "the friend Simon went
1513        // WITH") or a transitive verb with an empty direct-object slot (gap = the
1514        // head: "the waterfall Derrick photographed"). A filled slot after the verb
1515        // is apposition, not a gap.
1516        let starts_object = |kind: Option<&TokenType>| {
1517            matches!(
1518                kind,
1519                Some(TokenType::Article(_))
1520                    | Some(TokenType::Noun(_))
1521                    | Some(TokenType::ProperName(_))
1522                    | Some(TokenType::Number(_))
1523                    | Some(TokenType::Cardinal(_))
1524                    | Some(TokenType::Possessive)
1525                    | Some(TokenType::Pronoun { .. })
1526                    | Some(TokenType::All)
1527                    | Some(TokenType::Some)
1528                    | Some(TokenType::No)
1529                    | Some(TokenType::Any)
1530                    | Some(TokenType::Most)
1531                    | Some(TokenType::Few)
1532                    | Some(TokenType::Many)
1533            )
1534        };
1535        let after_verb = self.tokens.get(vp + 1).map(|t| &t.kind);
1536        if matches!(after_verb, Some(TokenType::Preposition(_)))
1537            && !starts_object(self.tokens.get(vp + 2).map(|t| &t.kind))
1538        {
1539            return true;
1540        }
1541        let verb_is_intransitive = self.tokens.get(vp).map_or(true, |t| {
1542            matches!(t.kind, TokenType::Verb { lemma, .. }
1543                if crate::lexicon::is_intransitive_verb(
1544                    &self.interner.resolve(lemma).to_lowercase()))
1545        });
1546        if verb_is_intransitive {
1547            return false;
1548        }
1549        !starts_object(after_verb)
1550    }
1551
1552    fn check_of_preposition(&self) -> bool {
1553        if let TokenType::Preposition(p) = self.peek().kind {
1554            p.is(self.interner, "of")
1555        } else {
1556            false
1557        }
1558    }
1559
1560    fn check_proper_name_or_label(&self) -> bool {
1561        match &self.peek().kind {
1562            TokenType::ProperName(_) => true,
1563            TokenType::Noun(s) => {
1564                let str_val = self.interner.resolve(*s);
1565                str_val.len() == 1 && str_val.chars().next().unwrap().is_uppercase()
1566            }
1567            _ => false,
1568        }
1569    }
1570
1571    fn check_possessive_pronoun(&self) -> bool {
1572        match &self.peek().kind {
1573            TokenType::Pronoun { case: Case::Possessive, .. } => true,
1574            TokenType::Ambiguous { primary, alternatives } => {
1575                let is_possessive = matches!(
1576                    **primary,
1577                    TokenType::Pronoun { case: Case::Possessive, .. }
1578                ) || alternatives.iter().any(|alt| {
1579                    matches!(alt, TokenType::Pronoun { case: Case::Possessive, .. })
1580                });
1581                if !is_possessive {
1582                    return false;
1583                }
1584                if self.noun_priority_mode {
1585                    return true;
1586                }
1587                // Outside noun-priority contexts, the possessive reading of an
1588                // object/possessive-ambiguous pronoun ("her") applies exactly
1589                // when an NP head follows: "saw her dog" vs "saw her".
1590                self.possessive_np_head_follows()
1591            }
1592            _ => false,
1593        }
1594    }
1595}