git » alan.git » commit efc7ba2

Add Hypothesis-based synthesis path and dev dependency

author Alan Dipert
2025-12-05 02:26:15 UTC
committer Alan Dipert
2025-12-05 02:26:15 UTC
parent 217ec849bb6b6dbf68d9bfafaa5a9a388431d132

Add Hypothesis-based synthesis path and dev dependency

README.md +9 -0
hypothesis_generator.py +217 -0
requirements-dev.txt +1 -0

diff --git a/README.md b/README.md
index 06ed33e..a82f154 100644
--- a/README.md
+++ b/README.md
@@ -116,6 +116,13 @@ Do **not** use the score as a sole hiring gate; treat it as one data point along
   - JSON output validated against the canonical feature-structure schema (`meta_schema.py`).
 - **Regeneration:** `main.py` will retry seeds until all properties pass; otherwise it fails loudly. Hardness knobs (`--min-irregular`, `--min-irregular-contrast`, `--min-ditransitive`, `--min-plural`, `--min-adjective`, `--min-fem-plural`, `--min-feature-load`) can be scaled in one go with `--hardness-multiplier` (e.g., `2.0` to roughly double thresholds). All chosen params are recorded in `generation_params` in the JSON and printed in the booklet for reproducibility.
 
+## Hypothesis-Driven Synthesis (Parallel Path)
+
+For an alternate “correct-by-construction” path, you can synthesize tests with Hypothesis strategies instead of RNG + filtering:
+- Install dev dep: `pip install -r requirements-dev.txt` (requires `hypothesis`).
+- Use `hypothesis_generator.synthesize_test(spec, blueprint, concepts, rng_seed=0)` to build a test dict that already respects section constraints and uniqueness (leveraging Hypothesis’s `find` to satisfy invariants).
+- This lives alongside the existing generator; no existing code paths were changed.
+
 ## Proctoring Guidance
 
 - Keep the cheat sheet and dictionary visible with the booklet; candidates should not need prior linguistics knowledge.
@@ -146,6 +153,8 @@ Do **not** use the score as a sole hiring gate; treat it as one data point along
 - `main.py` — CLI to generate JSON; retries until all properties pass.
 - `Makefile` — `make run` builds everything; `make clean` removes artifacts.
 - `answer_key.txt`, `test_booklet.txt`, `generated_test.json` — outputs from the last generation.
+- `hypothesis_generator.py` — optional Hypothesis-based synthesizer (builds items by construction rather than RNG filtering). See "Hypothesis-driven synthesis" below.
+- `requirements-dev.txt` — dev-only dependencies (Hypothesis).
 
 ## Taking the Test (Candidate View)
 
diff --git a/hypothesis_generator.py b/hypothesis_generator.py
new file mode 100644
index 0000000..6f63aa9
--- /dev/null
+++ b/hypothesis_generator.py
@@ -0,0 +1,217 @@
+"""Hypothesis-driven synthesis of ALAN tests (parallel to the existing generator).
+
+This module constructs questions/tests by *finding* examples that satisfy the same
+invariants enforced elsewhere, rather than generating and discarding with RNG.
+It leaves the existing generator untouched.
+"""
+from __future__ import annotations
+
+from dataclasses import replace
+from typing import Dict, List, Optional
+import random
+
+from hypothesis import strategies as st
+from hypothesis import find, settings
+
+from language_spec import (
+    LanguageSpec,
+    SentenceFeatures,
+    NPFeature,
+    AGENT,
+    RECIPIENT,
+    THEME,
+    realize_sentence,
+    english_gloss,
+)
+from test_blueprint import TestBlueprint, Concept, SectionBlueprint
+from test_generator import (
+    Option,
+    Question,
+    question_valid,
+    build_distractors,
+    section_constraints,
+    respects_constraints,
+    sentence_features,
+)
+from semantic import to_meaning
+
+ADJ_POOL = ["tall", "red", "big", "fast"]
+
+
+def _np_strategy(cons, role: str) -> st.SearchStrategy[NPFeature]:
+    """Generate NPFeature respecting section constraints."""
+    if role == AGENT:
+        nouns = cons.allowed_agent_nouns
+    elif role == THEME:
+        nouns = cons.allowed_theme_nouns
+    else:
+        nouns = cons.allowed_recipient_nouns
+    adj_strategy = st.just([])
+    if cons.allow_adjectives:
+        adj_strategy = st.one_of(
+            st.just([]),
+            st.lists(st.sampled_from(ADJ_POOL), min_size=1, max_size=1),
+        )
+    return st.builds(
+        NPFeature,
+        noun_id=st.sampled_from(nouns),
+        feminine=st.booleans() if cons.allow_feminine else st.just(False),
+        plural=st.booleans() if cons.allow_plural else st.just(False),
+        adjectives=adj_strategy,
+        role=st.just(role),
+        use_irregular=st.booleans() if cons.allow_irregulars else st.just(False),
+    )
+
+
+def _sentence_strategy(cons) -> st.SearchStrategy[SentenceFeatures]:
+    """SentenceFeatures within section constraints (includes valence/tense)."""
+    verb_strategy = st.sampled_from(cons.allowed_verbs)
+    def _build(verb_id: str, subj: NPFeature, obj1: NPFeature, obj2: Optional[NPFeature], tense: str, use_irregular_verb: bool) -> SentenceFeatures:
+        return sentence_features(
+            verb_id=verb_id,
+            tense=tense,
+            subj=subj,
+            obj1=obj1,
+            obj2=obj2,
+            use_irregular_verb=use_irregular_verb,
+        )
+
+    def _obj2_strategy(verb_id: str) -> st.SearchStrategy[Optional[NPFeature]]:
+        if verb_id != "give" or not cons.allow_ditransitive:
+            return st.just(None)
+        return _np_strategy(cons, THEME)
+
+    def _tense_strategy() -> st.SearchStrategy[str]:
+        if cons.allow_past:
+            return st.sampled_from(["PRES", "PAST"])
+        return st.just("PRES")
+
+    return st.deferred(
+        lambda: st.builds(
+            _build,
+            verb_strategy,
+            _np_strategy(cons, AGENT),
+            _np_strategy(cons, RECIPIENT),
+            st.none(),
+            _tense_strategy(),
+            st.booleans() if cons.allow_irregulars else st.just(False),
+        ).flatmap(
+            # attach obj2 based on verb to keep valence aligned
+            lambda sf: st.builds(
+                _build,
+                st.just(sf.verb_id),
+                st.just(sf.subject),
+                st.just(sf.obj1),
+                _obj2_strategy(sf.verb_id),
+                st.just(sf.tense),
+                st.just(sf.use_irregular_verb),
+            )
+        )
+    )
+
+
+def _question_from_sf(spec: LanguageSpec, sf: SentenceFeatures, section_id: str, concepts: List[str], item_type: str, rng: random.Random, constraints) -> Optional[Question]:
+    """Create a Question from a SentenceFeatures, returning None if invalid."""
+    correct_text = realize_sentence(spec, sf)
+    gloss = english_gloss(sf)
+    distractors = build_distractors(spec, sf, rng, constraints=constraints)
+    options = [Option(label="", text=correct_text, is_correct=True, explanation="Correct", features=sf)] + distractors
+    labels = ["A", "B", "C", "D"]
+    rng.shuffle(options)
+    for i, opt in enumerate(options):
+        opt.label = labels[i]
+    stem = f"Use the rules to choose the correct sentence. Target meaning: {gloss}" if item_type != "TRANSLATE_TO_LANG" else f"Translate into the language: {gloss}"
+    q = Question(
+        id=f"{section_id}_{rng.randrange(10_000)}",
+        item_type=item_type,
+        section_id=section_id,
+        concepts=concepts,
+        stem=stem,
+        options=options,
+        difficulty_score=0.5,
+    )
+    return q if question_valid(q, spec) else None
+
+
+def synthesize_question(
+    spec: LanguageSpec,
+    cons,
+    section: SectionBlueprint,
+    item_type: str,
+    seen_meanings: set,
+    seen_surfaces: set,
+    rng_seed: int = 0,
+) -> Question:
+    """Find a single valid question respecting uniqueness constraints."""
+    sf_strategy = _sentence_strategy(cons)
+
+    def _valid_sf(sf: SentenceFeatures) -> bool:
+        rng = random.Random(rng_seed)
+        if not respects_constraints(sf, cons):
+            return False
+        q = _question_from_sf(spec, sf, section.id, section.focus_concepts, item_type, rng, cons)
+        if q is None or not q.options:
+            return False
+        correct = next(o for o in q.options if o.is_correct)
+        meaning_key = repr(to_meaning(correct.features))
+        surface = correct.text
+        if meaning_key in seen_meanings:
+            return False
+        if surface in seen_surfaces:
+            return False
+        return True
+
+    sf_example = find(sf_strategy, _valid_sf, settings=settings(max_examples=500, database=None))
+    # Recreate question with fresh RNG to align labels
+    rng = random.Random(rng_seed + 1)
+    q = _question_from_sf(spec, sf_example, section.id, section.focus_concepts, item_type, rng, cons)
+    if q is None:
+        raise AssertionError("Hypothesis produced an invalid question unexpectedly.")
+    correct = next(o for o in q.options if o.is_correct)
+    meaning_key = repr(to_meaning(correct.features))
+    surface = correct.text
+    seen_meanings.add(meaning_key)
+    seen_surfaces.add(surface)
+    return q
+
+
+def synthesize_test(
+    spec: LanguageSpec,
+    blueprint: TestBlueprint,
+    concepts: Dict[str, Concept],
+    rng_seed: int = 0,
+) -> Dict:
+    """Build a full test using Hypothesis to find valid items by construction."""
+    sections_out = []
+    question_counter = 1
+    seen_meanings: set = set()
+    seen_surfaces: set = set()
+    unlocked: set[str] = set()
+    for section in blueprint.sections:
+        unlocked |= set(section.introduce_concepts)
+        cons = section_constraints(unlocked)
+        questions: List[Question] = []
+        section_intro = [f"{concepts[cid].description_en}" for cid in section.introduce_concepts]
+        for idx in range(section.num_items):
+            item_type = section.item_types[idx % len(section.item_types)]
+            q = synthesize_question(spec, cons, section, item_type, seen_meanings, seen_surfaces, rng_seed + idx)
+            q_dict = asdict(q)
+            q_dict["number"] = question_counter
+            question_counter += 1
+            questions.append(q_dict)
+        sections_out.append(
+            {
+                "id": section.id,
+                "introduce_concepts": section.introduce_concepts,
+                "intro_text": section_intro,
+                "questions": questions,
+            }
+        )
+    return {
+        "meta": {
+            "version": "hypothesis-0.1",
+            "description": "Alan's Language Aptitude iNstrument (ALAN) synthesized with Hypothesis",
+            "seed": rng_seed,
+        },
+        "sections": sections_out,
+    }
diff --git a/requirements-dev.txt b/requirements-dev.txt
new file mode 100644
index 0000000..4897858
--- /dev/null
+++ b/requirements-dev.txt
@@ -0,0 +1 @@
+hypothesis>=6.88.0