diff --git a/src/gramforge/grammar.py b/src/gramforge/grammar.py index 90d9827..36619a9 100644 --- a/src/gramforge/grammar.py +++ b/src/gramforge/grammar.py @@ -57,41 +57,50 @@ def wrapper(*args, **kwargs): def Substitution(template, lang=None): def replace_template(template, a): - # Make numbers formattable 0 -> {0} - wrap = lambda s: (re.sub(r'(\d+)', r'{\1}', s) if isinstance(s, str) else s) - - # Function to safely replace only unescaped '?' - def replace_match(m): - slot_idx = int(m.group(1)) - replacement = m.group(2) - # The children args 'a' might be FastProduction nodes, so render them if needed - arg_to_sub = a[slot_idx] - if not isinstance(arg_to_sub, str): - arg_to_sub = arg_to_sub.render(lang) - return re.sub(r'(?", '0 1'), + ) + node = _node(r_fn, _fp(ra), _fp(rb)) + assert node.render('eng') == '' + + +# ── 5. FOL pattern unit tests ───────────────────────────────────────────── + +class TestFOLPatternsUnit: + """Test specific FOL template patterns using minimal hand-built grammars.""" + + def test_entity_substituted_into_property_tptp(self): + # langs=['tptp','eng'] so first template → tptp, second → eng + R = init_grammar(['tptp', 'eng']) + r_ent = R('entity', 'mary', 'Mary') + r_prop = R('property', 'tall(?)', 'is tall') + # tptp: '1[?←0]' = render property (arg1), replace ? with literal '0', + # wrap turns '0' → '{0}', format fills {0} with entity. + r_term = R('term(entity,property)', '1[?←0]', '0 1') + node = _node(r_term, _fp(r_ent), _fp(r_prop)) + assert node.render('tptp') == 'tall(mary)' + + def test_entity_substituted_into_predicate(self): + R = init_grammar(['tptp', 'eng']) + r_ent = R('entity', 'paul', 'Paul') + r_prop = R('property', 'preda(?)', 'is preda') + r_term = R('term(entity,property)', '1[?←0]', '0 1') + node = _node(r_term, _fp(r_ent), _fp(r_prop)) + assert node.render('tptp') == 'preda(paul)' + + def test_x_quantifier_leaves_question_mark_for_parent(self): + # X_quantifier renders with '?' intact so a parent Substitution can fill it. + R = init_grammar(['tptp', 'eng']) + r_q = R('quantifier', '!', 'everyone') + r_g = R('group', 'room', 'in the room') + r_xq = R('X_quantifier(quantifier,group)', '0[X]:(1(X)=>(?))','0 1') + node = _node(r_xq, _fp(r_q), _fp(r_g)) + tptp = node.render('tptp') + assert tptp == '![X]:(room(X)=>(?))', tptp + assert '?' in tptp # placeholder intact for parent substitution + + def test_universal_quantifier_full_chain(self): + """term(X_quantifier, X_property) fills ? in X_quantifier with property.""" + R = init_grammar(['tptp', 'eng']) + r_q = R('quantifier', '!', 'everyone') + r_g = R('group', 'room', 'in the room') + r_xq = R('X_quantifier(quantifier,group)', '0[X]:(1(X)=>(?))','0 1') + r_adj = R('adjective', 'tall', 'tall') + r_prop = R('property(adjective)', '0(?)', 'is 0') + r_xp = R('X_property(property)', '0[?←X]', '0') + r_term = R('term(X_quantifier,X_property)', '0[?←1]', '0 1') + + xp = _node(r_xp, _node(r_prop, _fp(r_adj))) + xq = _node(r_xq, _fp(r_q), _fp(r_g)) + term = _node(r_term, xq, xp) + + assert term.render('tptp') == '![X]:(room(X)=>(tall(X)))' + + def test_existential_quantifier_chain(self): + """E_quantifier fills ? in the existential template; E_property supplies the predicate.""" + R = init_grammar(['tptp', 'eng']) + r_g = R('group', 'room', 'in the room') + # Note: \?[X]:(...) has no ← → preprocessing runs and wraps '0' → '{0}' + r_eq = R('E_quantifier(group)', r'\?[X]:(0(X)&(?))', 'someone 0') + r_adj = R('adjective', 'tall', 'tall') + r_prop = R('property(adjective)', '0(?)', 'is 0') + r_ep = R('E_property(property)', '0[?←X]', '0') + r_term = R('term(E_quantifier,E_property)', '0[?←1]', '0 1') + + ep = _node(r_ep, _node(r_prop, _fp(r_adj))) + eq = _node(r_eq, _fp(r_g)) + term = _node(r_term, eq, ep) + + assert term.render('tptp') == '?[X]:(room(X)&(tall(X)))' + + def test_adjective_chain_constraint_rejects_duplicate(self): + """Constraint('1∉0') must block an adjective already in the chain.""" + R = init_grammar(['eng', 'tptp']) + r_old = R('adjective', 'old(?)', 'old') + r_chain_lf = R('adjective_chain(adjective)', '0(?)', '0') + r_chain_ext = R('adjective_chain(adjective_chain,adjective)', + '0&1(?)', '0 1', constraint=Constraint('1∉0')) + + chain = _node(r_chain_lf, _fp(r_old)) + ext = _node(r_chain_ext, chain, _fp(r_old)) # same adjective repeated + assert ext.check() is False + + def test_adjective_chain_constraint_allows_distinct(self): + R = init_grammar(['eng', 'tptp']) + r_old = R('adjective', 'old(?)', 'old') + r_tall = R('adjective', 'tall(?)', 'tall') + r_chain_lf = R('adjective_chain(adjective)', '0(?)', '0') + r_chain_ext = R('adjective_chain(adjective_chain,adjective)', + '0&1(?)', '0 1', constraint=Constraint('1∉0')) + + chain = _node(r_chain_lf, _fp(r_old)) + ext = _node(r_chain_ext, chain, _fp(r_tall)) # distinct adjective + assert ext.check() is True + + +# ── 6. FOL generation smoke tests ───────────────────────────────────────── + +@pytest.fixture(scope='module') +def fol_grammar(): + return FOL_grammar(N_PREMS=6) + + +SEEDS = list(range(60)) + + +@pytest.mark.parametrize('seed', SEEDS) +def test_fol_no_bare_question_mark_in_eng(fol_grammar, seed): + sample = generate(fol_grammar.start(), depth=8, min_depth=5, seed=seed) + eng = sample @ 'eng' + assert '?' not in eng, f"Bare ? leaked into english (seed={seed}): {eng!r}" + + +@pytest.mark.parametrize('seed', SEEDS) +def test_fol_no_unresolved_placeholder_in_tptp(fol_grammar, seed): + sample = generate(fol_grammar.start(), depth=8, min_depth=5, seed=seed) + tptp = sample @ 'tptp' + # '(?)' means a property placeholder was never filled by an entity. + assert '(?)' not in tptp, f"Unresolved '(?)' in tptp (seed={seed}): {tptp!r}" + + +@pytest.mark.parametrize('seed', SEEDS) +def test_fol_tptp_has_no_double_whitespace(fol_grammar, seed): + sample = generate(fol_grammar.start(), depth=8, min_depth=5, seed=seed) + assert ' ' not in (sample @ 'tptp'), f"Double space in tptp (seed={seed})" + + +@pytest.mark.parametrize('seed', SEEDS) +def test_fol_constraint_no_free_x_without_quantifier(fol_grammar, seed): + """Free variable X in tptp means the no_free_var constraint wasn't applied.""" + sample = generate(fol_grammar.start(), depth=8, min_depth=5, seed=seed) + tptp = sample @ 'tptp' + # If X appears, a universal [X]: or existential ?[...X...] must bind it. + if 'X' in tptp: + assert re.search(r'[\?\!]\[.*X.*\]', tptp), ( + f"Free variable X with no binding quantifier (seed={seed}): {tptp!r}" + ) + + +# ── 7. Depth target tests ────────────────────────────────────────────────── + +@pytest.mark.parametrize('seed', SEEDS) +def test_fol_generated_height_within_depth_bounds(fol_grammar, seed): + """Generated trees must respect both min_depth and depth (max_depth).""" + sample = generate(fol_grammar.start(), depth=8, min_depth=5, seed=seed) + h = sample.height + assert h >= 5, f"height {h} < min_depth 5 (seed={seed}): {sample@'eng'!r}" + assert h <= 8, f"height {h} > depth 8 (seed={seed}): {sample@'eng'!r}" + + +def test_generate_respects_depth_bounds_simple_grammar(): + """min_depth and depth are respected on a small recursive grammar.""" + R = init_grammar(['eng']) + R('s(s)', '0') # recursive: s expands to s (increases depth) + R('s', 'x') # terminal: s → 'x' + + for seed in range(30): + result = generate(R.start(), depth=5, min_depth=3, seed=seed) + assert 3 <= result.height <= 5, ( + f"height {result.height} not in [3, 5] (seed={seed})" + ) + + +def test_generate_with_only_max_depth_allows_any_height_up_to_max(): + """Without min_depth, height 0..depth are all valid.""" + R = init_grammar(['eng']) + R('s(s)', '0') + R('s', 'x') + + heights = set() + for seed in range(50): + result = generate(R.start(), depth=4, seed=seed) + h = result.height + assert 0 <= h <= 4, f"height {h} > depth 4 (seed={seed})" + heights.add(h) + # With a purely recursive grammar across 50 seeds we should see variation + assert len(heights) > 1, "Expected height variation across seeds"