| author | Alan Dipert
<alan@dipert.org> 2025-12-05 02:26:15 UTC |
| committer | Alan Dipert
<alan@dipert.org> 2025-12-05 02:26:15 UTC |
| parent | 217ec849bb6b6dbf68d9bfafaa5a9a388431d132 |
| 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