diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9851ad1..29cc878 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,4 +1,14 @@ -name: Publish to PyPI +name: Release + +# Publishes a release to PyPI using OIDC trusted publishing (no API tokens) +# and creates a matching GitHub Release. Triggered by pushing a version tag, +# e.g. `git tag v0.2.3 && git push origin v0.2.3`. +# +# Required one-time configuration (repository settings): +# - PyPI trusted publisher: project `glazing`, owner `factslab`, +# repository `glazing`, workflow `publish.yml`, environment `pypi`. +# - GitHub environment `pypi` (optionally with required reviewers / tag +# protection rules) that gates the publish job. on: push: @@ -23,22 +33,27 @@ jobs: - name: Install build dependencies run: | python -m pip install --upgrade pip - pip install build + pip install build twine - name: Build distribution run: python -m build + - name: Check distribution + run: twine check dist/* + - name: Upload artifacts uses: actions/upload-artifact@v4 with: name: dist path: dist/ - publish: + publish-pypi: name: Publish to PyPI needs: build runs-on: ubuntu-latest - environment: pypi + environment: + name: pypi + url: https://pypi.org/p/glazing permissions: id-token: write steps: @@ -50,3 +65,26 @@ jobs: - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 + + github-release: + name: GitHub Release + needs: publish-pypi + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ github.token }} + run: >- + gh release create "${{ github.ref_name }}" + --repo "${{ github.repository }}" + --title "${{ github.ref_name }}" + --generate-notes + dist/* diff --git a/CHANGELOG.md b/CHANGELOG.md index b6e3a33..0bb6772 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.3] - 2026-06-25 + +### Added + +- **WordNet adjective syntactic-position markers** (`p` predicative, `a` attributive, `ip` immediately postnominal) are now parsed off adjective words into the new `Word.syntactic_marker` field (#8) + +### Fixed + +- **WordNet synset `lex_filename`** now derives from the canonical WordNet `lexnames` lexicographer-file table; previously every entry from index 2 onward was shifted, so e.g. `lex_filenum=29` (`verb.body`) was mislabeled `noun.time` and `lex_filenum=3` (`noun.Tops`) was mislabeled `adv.all` (#9) +- **WordNet adjective synsets carrying a syntactic marker** are no longer silently dropped during conversion; the parenthesized marker previously stayed on the lemma, failed lemma validation, and discarded the entire synset (#8) +- **FrameNet FE Requires/Excludes constraints** now populate `FrameElement.requires_fe` and `FrameElement.excludes_fe`; the converter previously read the (nonexistent) child elements of the first ``/`` tag rather than the `name` attribute of each sibling, so the lists were always empty (#7) + ## [0.2.2] - 2026-02-06 ### Added diff --git a/docs/api/index.md b/docs/api/index.md index b42cbcd..dc0a2e8 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -118,7 +118,7 @@ except ValidationError as e: ## Version Compatibility -This documentation covers Glazing version 0.2.2. Check your installed version: +This documentation covers glazing version 0.2.3. Check your installed version: ```python import glazing diff --git a/docs/citation.md b/docs/citation.md index c05d7a1..ffc78f0 100644 --- a/docs/citation.md +++ b/docs/citation.md @@ -12,22 +12,22 @@ If you use Glazing in your research, please cite our work. title = {Glazing: Unified Data Models and Interfaces for Syntactic and Semantic Frame Ontologies}, year = {2025}, url = {https://github.com/factslab/glazing}, - version = {0.2.2}, + version = {0.2.3}, doi = {10.5281/zenodo.17467082} } ``` ### APA -White, A. S. (2025). *Glazing: Unified Data Models and Interfaces for Syntactic and Semantic Frame Ontologies* (Version 0.2.2) [Computer software]. https://github.com/factslab/glazing +White, A. S. (2025). *Glazing: Unified Data Models and Interfaces for Syntactic and Semantic Frame Ontologies* (Version 0.2.3) [Computer software]. https://github.com/factslab/glazing ### Chicago -White, Aaron Steven. 2025. *Glazing: Unified Data Models and Interfaces for Syntactic and Semantic Frame Ontologies*. Version 0.2.2. https://github.com/factslab/glazing. +White, Aaron Steven. 2025. *Glazing: Unified Data Models and Interfaces for Syntactic and Semantic Frame Ontologies*. Version 0.2.3. https://github.com/factslab/glazing. ### MLA -White, Aaron Steven. *Glazing: Unified Data Models and Interfaces for Syntactic and Semantic Frame Ontologies*. Version 0.2.2, 2025, https://github.com/factslab/glazing. +White, Aaron Steven. *Glazing: Unified Data Models and Interfaces for Syntactic and Semantic Frame Ontologies*. Version 0.2.3, 2025, https://github.com/factslab/glazing. ## Citing Datasets diff --git a/docs/index.md b/docs/index.md index f4a55f9..1b3b235 100644 --- a/docs/index.md +++ b/docs/index.md @@ -93,7 +93,7 @@ If you use Glazing in your research, please cite: title = {Glazing: Unified Data Models and Interfaces for Syntactic and Semantic Frame Ontologies}, year = {2025}, url = {https://github.com/factslab/glazing}, - version = {0.2.2}, + version = {0.2.3}, doi = {10.5281/zenodo.17467082} } ``` diff --git a/pyproject.toml b/pyproject.toml index d8411ff..3870dda 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "glazing" -version = "0.2.2" +version = "0.2.3" description = "Unified data models and interfaces for syntactic and semantic frame ontologies" readme = "README.md" requires-python = ">=3.13" @@ -38,7 +38,10 @@ dependencies = [ [project.optional-dependencies] dev = [ "ruff>=0.1.9", - "mypy>=1.8.0", + # Pinned: --strict behavior changes between minor releases, so an unpinned + # spec lets CI and local environments drift (and CI's Type Check fail on + # code that passes locally). Bump deliberately. + "mypy==2.1.0", "pytest>=7.4.3", "pytest-cov>=4.1.0", "pytest-asyncio>=0.21.1", diff --git a/src/glazing/__version__.py b/src/glazing/__version__.py index 051b025..6d75b81 100644 --- a/src/glazing/__version__.py +++ b/src/glazing/__version__.py @@ -1,4 +1,4 @@ """Version information for the glazing package.""" -__version__ = "0.2.2" +__version__ = "0.2.3" __version_info__ = tuple(int(i) for i in __version__.split(".")) diff --git a/src/glazing/downloader.py b/src/glazing/downloader.py index f5f97d8..d3a8aa5 100644 --- a/src/glazing/downloader.py +++ b/src/glazing/downloader.py @@ -617,17 +617,15 @@ def get_downloader(dataset: DatasetType | str) -> BaseDownloader: >>> print(downloader.version) 3.4 """ - # Normalize to lowercase for case-insensitive lookup - dataset_lower = dataset.lower() - - if dataset_lower not in _DOWNLOADERS: + # Normalize to lowercase for case-insensitive lookup. The cast narrows the + # plain ``str`` to the key type for the lookup; ``.get`` still returns None + # for any unsupported name, so the cast never asserts a false membership. + dataset_lower = cast(DatasetType, dataset.lower()) + downloader_class = _DOWNLOADERS.get(dataset_lower) + if downloader_class is None: supported = ", ".join(_DOWNLOADERS.keys()) msg = f"Unsupported dataset: {dataset}. Supported: {supported}" raise ValueError(msg) - - # Cast to DatasetType for type checking - dataset_typed = cast(DatasetType, dataset_lower) - downloader_class = _DOWNLOADERS[dataset_typed] return downloader_class() diff --git a/src/glazing/framenet/converter.py b/src/glazing/framenet/converter.py index a3054d5..2f3736b 100644 --- a/src/glazing/framenet/converter.py +++ b/src/glazing/framenet/converter.py @@ -274,20 +274,16 @@ def _parse_fe_constraints(self, fe_elem: etree._Element) -> tuple[list[str], lis tuple[list[str], list[str]] Requires and excludes lists. """ - requires_fe = [] - excludes_fe = [] - - requires_elem = fe_elem.find( - f"{{{self.namespace}}}requiresFE" if self.namespace else "requiresFE" - ) - if requires_elem is not None: - requires_fe = [req.get("name", "") for req in requires_elem] - - excludes_elem = fe_elem.find( - f"{{{self.namespace}}}excludesFE" if self.namespace else "excludesFE" - ) - if excludes_elem is not None: - excludes_fe = [exc.get("name", "") for exc in excludes_elem] + # FrameNet frame XML carries each constraint as a self-closing sibling + # of , e.g. . There can be + # several of each, and the target FE name lives in the ``name`` + # attribute, not in a child element. Collect every matching sibling. + requires_fe = [ + name for req in fe_elem.findall(self._tag("requiresFE")) if (name := req.get("name")) + ] + excludes_fe = [ + name for exc in fe_elem.findall(self._tag("excludesFE")) if (name := exc.get("name")) + ] return requires_fe, excludes_fe diff --git a/src/glazing/wordnet/converter.py b/src/glazing/wordnet/converter.py index cdb498d..8a72c03 100644 --- a/src/glazing/wordnet/converter.py +++ b/src/glazing/wordnet/converter.py @@ -50,12 +50,17 @@ Word, ) from glazing.wordnet.types import ( + AdjPosition, LexFileName, PointerSymbol, VerbFrameNumber, WordNetPOS, ) +# Adjective syntactic-position markers appended to words in adjective data files, +# e.g. "galore(ip)" -> ("galore", "ip"). See wndb(5) / wninput(5). +ADJ_MARKER_RE = re.compile(r"^(?P.+)\((?Pip|a|p)\)$") + class WordNetConverter: """Parse WordNet database files into structured models. @@ -77,53 +82,57 @@ class WordNetConverter: Convert entire WordNet database to JSON Lines. """ - # Mapping from lexical file numbers to names + # Mapping from lexical file numbers to names. + # + # This follows the canonical WordNet ``lexnames`` lexicographer-file table + # (see wndb(5)). Note that ``adv.all`` is index 2 and ``adj.ppl`` is the + # final entry at index 44 -- the order is *not* a simple POS grouping. LEX_FILE_NAMES: ClassVar[dict[int, LexFileName]] = { 0: "adj.all", 1: "adj.pert", - 2: "adj.ppl", - 3: "adv.all", - 4: "noun.Tops", - 5: "noun.act", - 6: "noun.animal", - 7: "noun.artifact", - 8: "noun.attribute", - 9: "noun.body", - 10: "noun.cognition", - 11: "noun.communication", - 12: "noun.event", - 13: "noun.feeling", - 14: "noun.food", - 15: "noun.group", - 16: "noun.location", - 17: "noun.motive", - 18: "noun.object", - 19: "noun.person", - 20: "noun.phenomenon", - 21: "noun.plant", - 22: "noun.possession", - 23: "noun.process", - 24: "noun.quantity", - 25: "noun.relation", - 26: "noun.shape", - 27: "noun.state", - 28: "noun.substance", - 29: "noun.time", - 30: "verb.body", - 31: "verb.change", - 32: "verb.cognition", - 33: "verb.communication", - 34: "verb.competition", - 35: "verb.consumption", - 36: "verb.contact", - 37: "verb.creation", - 38: "verb.emotion", - 39: "verb.motion", - 40: "verb.perception", - 41: "verb.possession", - 42: "verb.social", - 43: "verb.stative", - 44: "verb.weather", + 2: "adv.all", + 3: "noun.Tops", + 4: "noun.act", + 5: "noun.animal", + 6: "noun.artifact", + 7: "noun.attribute", + 8: "noun.body", + 9: "noun.cognition", + 10: "noun.communication", + 11: "noun.event", + 12: "noun.feeling", + 13: "noun.food", + 14: "noun.group", + 15: "noun.location", + 16: "noun.motive", + 17: "noun.object", + 18: "noun.person", + 19: "noun.phenomenon", + 20: "noun.plant", + 21: "noun.possession", + 22: "noun.process", + 23: "noun.quantity", + 24: "noun.relation", + 25: "noun.shape", + 26: "noun.state", + 27: "noun.substance", + 28: "noun.time", + 29: "verb.body", + 30: "verb.change", + 31: "verb.cognition", + 32: "verb.communication", + 33: "verb.competition", + 34: "verb.consumption", + 35: "verb.contact", + 36: "verb.creation", + 37: "verb.emotion", + 38: "verb.motion", + 39: "verb.perception", + 40: "verb.possession", + 41: "verb.social", + 42: "verb.stative", + 43: "verb.weather", + 44: "adj.ppl", } def parse_data_file(self, filepath: Path | str, pos: WordNetPOS) -> list[Synset]: @@ -628,6 +637,30 @@ def convert_exceptions(self, wordnet_dir: Path | str, output_file: Path | str) - return len(all_entries) + @staticmethod + def _split_adj_marker(word: str) -> tuple[str, AdjPosition | None]: + """Split an adjective syntactic-position marker off a word token. + + In WordNet adjective data files the syntactic marker is appended, in + parentheses, directly onto the word with no intervening space -- e.g. + ``galore(ip)``, ``outback(a)``, ``ready_to_hand(p)``. See wndb(5). + + Parameters + ---------- + word : str + Raw word token from a data file (possibly marker-bearing). + + Returns + ------- + tuple[str, AdjPosition | None] + The lemma with the marker stripped, and the marker (``p``/``a``/ + ``ip``) if present, otherwise ``None``. + """ + match = ADJ_MARKER_RE.match(word) + if match is None: + return word, None + return match.group("lemma"), cast(AdjPosition, match.group("marker")) + def _parse_data_line(self, line: str) -> Synset | None: """Parse a line from WordNet data file. @@ -669,9 +702,9 @@ def _parse_data_line(self, line: str) -> Synset | None: for _ in range(w_cnt): if idx + 1 >= len(parts): break - lemma = parts[idx] + lemma, marker = self._split_adj_marker(parts[idx]) lex_id = int(parts[idx + 1], 16) - words.append(Word(lemma=lemma, lex_id=lex_id)) + words.append(Word(lemma=lemma, lex_id=lex_id, syntactic_marker=marker)) idx += 2 # Parse pointers diff --git a/src/glazing/wordnet/models.py b/src/glazing/wordnet/models.py index 61fba80..ccf861f 100644 --- a/src/glazing/wordnet/models.py +++ b/src/glazing/wordnet/models.py @@ -45,6 +45,7 @@ from glazing.base import GlazingBaseModel from glazing.types import LEMMA_PATTERN from glazing.wordnet.types import ( + AdjPosition, LexFileName, LexID, PointerSymbol, @@ -70,6 +71,10 @@ class Word(GlazingBaseModel): Frequency-based sense ordering from index.sense. tag_count : int, default=0 Semantic concordance tag count. + syntactic_marker : AdjPosition | None, default=None + Adjective syntactic-position marker from wndb(5): ``p`` (predicative), + ``a`` (attributive/prenominal), or ``ip`` (immediately postnominal). + Only adjectives carry a marker; ``None`` for all other parts of speech. Examples -------- @@ -78,12 +83,17 @@ class Word(GlazingBaseModel): 'dog' >>> word.lex_id 0 + >>> Word(lemma="galore", lex_id=0, syntactic_marker="ip").syntactic_marker + 'ip' """ lemma: str = Field(description="Word form (lowercase, underscores for spaces)") lex_id: LexID = Field(description="Lexical ID distinguishing same word in synset") sense_number: int | None = Field(default=None, description="Frequency-based sense ordering") tag_count: int = Field(default=0, ge=0, description="Semantic concordance tag count") + syntactic_marker: AdjPosition | None = Field( + default=None, description="Adjective syntactic-position marker (p/a/ip)" + ) @field_validator("lemma") @classmethod diff --git a/tests/test_framenet/test_converter.py b/tests/test_framenet/test_converter.py index c79079b..03bf428 100644 --- a/tests/test_framenet/test_converter.py +++ b/tests/test_framenet/test_converter.py @@ -336,3 +336,104 @@ def test_lu_index_missing_no_crash(self, tmp_path): assert data["id"] == 100 assert "lexical_units" in data assert len(data["lexical_units"]) == 0 + + +class TestFEConstraints: + """Test FE Requires/Excludes constraint parsing (issue #7).""" + + # Frame XML in the FrameNet default namespace, mirroring the real + # Personal_relationship frame: requiresFE/excludesFE are self-closing + # siblings of whose target name lives in the ``name`` attribute. + FRAME_XML = """ + + A frame about relationships. + + The first partner. + + + + + The second partner. + + + + + Both partners together. + + + + + When the relationship holds. + + """ + + @pytest.fixture + def frame_file(self, tmp_path): + """Write the constraint-bearing frame XML to a temp file.""" + path = tmp_path / "Personal_relationship.xml" + path.write_text(self.FRAME_XML, encoding="utf-8") + return path + + def test_requires_and_excludes_populated(self, frame_file): + """requiresFE/excludesFE names are captured from the ``name`` attribute.""" + converter = FrameNetConverter() + frame = converter.convert_frame_file(frame_file) + by_name = {fe.name: fe for fe in frame.frame_elements} + + assert by_name["Partner_1"].requires_fe == ["Partner_2"] + assert by_name["Partner_1"].excludes_fe == ["Partners"] + assert by_name["Partner_2"].requires_fe == ["Partner_1"] + assert by_name["Partner_2"].excludes_fe == ["Partners"] + + def test_multiple_excludes_on_one_fe(self, frame_file): + """An FE with several siblings keeps all of them.""" + converter = FrameNetConverter() + frame = converter.convert_frame_file(frame_file) + partners = next(fe for fe in frame.frame_elements if fe.name == "Partners") + + assert partners.excludes_fe == ["Partner_1", "Partner_2"] + assert partners.requires_fe == [] + + def test_fe_without_constraints_is_empty(self, frame_file): + """An FE with no constraint elements yields empty lists, not None.""" + converter = FrameNetConverter() + frame = converter.convert_frame_file(frame_file) + time_fe = next(fe for fe in frame.frame_elements if fe.name == "Time") + + assert time_fe.requires_fe == [] + assert time_fe.excludes_fe == [] + + def test_constraints_survive_json_round_trip(self, tmp_path, frame_file): + """Constraints are present in the serialized JSONL output.""" + converter = FrameNetConverter() + output_file = tmp_path / "frames.jsonl" + converter.convert_frames_directory(frame_file.parent, output_file) + + with output_file.open("r", encoding="utf-8") as f: + data = json.loads(f.readline()) + + by_name = {fe["name"]: fe for fe in data["frame_elements"]} + assert by_name["Partner_1"]["requires_fe"] == ["Partner_2"] + assert by_name["Partner_1"]["excludes_fe"] == ["Partners"] + assert by_name["Partners"]["excludes_fe"] == ["Partner_1", "Partner_2"] + + def test_real_personal_relationship_frame(self): + """Cross-check against the real FN 1.7 frame file if available.""" + frame_path = Path("framenet_v17/frame/Personal_relationship.xml") + if not frame_path.exists(): + pytest.skip("Real FrameNet data not available") + + converter = FrameNetConverter() + frame = converter.convert_frame_file(frame_path) + by_name = {fe.name: fe for fe in frame.frame_elements} + + # At least one FE must carry a non-empty constraint list. + assert any(fe.requires_fe for fe in frame.frame_elements) + assert any(fe.excludes_fe for fe in frame.frame_elements) + assert by_name["Partner_1"].requires_fe == ["Partner_2"] + assert "Partners" in by_name["Partner_1"].excludes_fe diff --git a/tests/test_wordnet/test_converter.py b/tests/test_wordnet/test_converter.py index f4bc050..a76c9f3 100644 --- a/tests/test_wordnet/test_converter.py +++ b/tests/test_wordnet/test_converter.py @@ -14,6 +14,7 @@ parse_index_file, parse_sense_index, ) +from glazing.wordnet.models import Synset class TestWordNetConverter: @@ -302,12 +303,92 @@ def test_is_valid_word_form(self, converter): assert not converter._is_valid_word_form("invalid@") def test_lex_file_names_mapping(self, converter): - """Test lexical file name mapping.""" - assert converter.LEX_FILE_NAMES[0] == "adj.all" - assert converter.LEX_FILE_NAMES[4] == "noun.Tops" - assert converter.LEX_FILE_NAMES[29] == "noun.time" - assert converter.LEX_FILE_NAMES[30] == "verb.body" - assert converter.LEX_FILE_NAMES[44] == "verb.weather" + """Test lexical file name mapping matches the canonical lexnames table. + + Regression test for the lex_filename/lex_filenum mismatch (issue #9): + adv.all is index 2 (not adj.ppl) and adj.ppl is the final entry at 44. + """ + # Canonical WordNet lexnames table (wndb(5)). + canonical = { + 0: "adj.all", + 1: "adj.pert", + 2: "adv.all", + 3: "noun.Tops", + 4: "noun.act", + 5: "noun.animal", + 6: "noun.artifact", + 7: "noun.attribute", + 8: "noun.body", + 9: "noun.cognition", + 10: "noun.communication", + 11: "noun.event", + 12: "noun.feeling", + 13: "noun.food", + 14: "noun.group", + 15: "noun.location", + 16: "noun.motive", + 17: "noun.object", + 18: "noun.person", + 19: "noun.phenomenon", + 20: "noun.plant", + 21: "noun.possession", + 22: "noun.process", + 23: "noun.quantity", + 24: "noun.relation", + 25: "noun.shape", + 26: "noun.state", + 27: "noun.substance", + 28: "noun.time", + 29: "verb.body", + 30: "verb.change", + 31: "verb.cognition", + 32: "verb.communication", + 33: "verb.competition", + 34: "verb.consumption", + 35: "verb.contact", + 36: "verb.creation", + 37: "verb.emotion", + 38: "verb.motion", + 39: "verb.perception", + 40: "verb.possession", + 41: "verb.social", + 42: "verb.stative", + 43: "verb.weather", + 44: "adj.ppl", + } + assert canonical == converter.LEX_FILE_NAMES + + # The POS prefix of each lex file name must agree with the synset type + # it can belong to. A noun lex_filenum must map to a noun.* name, etc. + assert converter.LEX_FILE_NAMES[3].startswith("noun.") + assert converter.LEX_FILE_NAMES[29].startswith("verb.") + assert converter.LEX_FILE_NAMES[2].startswith("adv.") + assert converter.LEX_FILE_NAMES[44].startswith("adj.") + + @pytest.mark.parametrize( + ("lex_filenum", "ss_type", "expected_name"), + [ + (3, "n", "noun.Tops"), # previously mislabeled "adv.all" + (28, "n", "noun.time"), + (29, "v", "verb.body"), # previously mislabeled "noun.time" + (43, "v", "verb.weather"), + (0, "a", "adj.all"), + (44, "a", "adj.ppl"), # previously mislabeled "verb.weather" + (2, "r", "adv.all"), # previously mislabeled "adj.ppl" + ], + ) + def test_parsed_synset_lex_filename_matches_filenum( + self, converter, lex_filenum, ss_type, expected_name + ): + """Parsed synsets derive lex_filename consistently from lex_filenum.""" + line = f"00001740 {lex_filenum:02d} {ss_type} 01 word 0 000 | a gloss" + synset = converter._parse_data_line(line) + assert synset is not None + assert synset.lex_filenum == lex_filenum + assert synset.lex_filename == expected_name + # The lex file name's POS prefix must be consistent with the synset type. + pos_prefix = {"n": "noun.", "v": "verb.", "r": "adv.", "a": "adj.", "s": "adj."} + assert synset.lex_filename.startswith(pos_prefix[ss_type]) def test_parse_data_file_empty_lines(self, converter, tmp_path): """Test data file parsing handles empty lines and comments.""" @@ -335,6 +416,95 @@ def test_parse_complex_gloss(self, converter, tmp_path): assert "nonliving" in synsets[0].gloss +class TestAdjectiveSyntacticMarkers: + """Test adjective syntactic-position marker parsing (issue #8).""" + + @pytest.fixture + def converter(self): + """Create WordNet converter instance.""" + return WordNetConverter() + + @pytest.mark.parametrize( + ("token", "expected"), + [ + ("galore(ip)", ("galore", "ip")), + ("outback(a)", ("outback", "a")), + ("ready_to_hand(p)", ("ready_to_hand", "p")), + ("out_of_reach(p)", ("out_of_reach", "p")), + ("dead-on(a)", ("dead-on", "a")), + # No marker: returned unchanged with None. + ("plain", ("plain", None)), + ("by-the-way", ("by-the-way", None)), + ("mother-in-law", ("mother-in-law", None)), + # Parenthetical that is not a valid marker must not be stripped. + ("foo(x)", ("foo(x)", None)), + ("note(2)", ("note(2)", None)), + ], + ) + def test_split_adj_marker(self, converter, token, expected): + """Markers are split off; non-marker tokens are left intact.""" + assert converter._split_adj_marker(token) == expected + + def test_marker_bearing_synset_is_retained(self, converter): + """A satellite adjective synset with a marker is no longer dropped. + + Before the fix the marker stayed on the lemma, failed lemma validation, + and the entire synset was silently discarded. + """ + line = ( + "00014377 00 s 02 abounding 0 galore(ip) 0 001 " + "& 00013906 a 0000 | existing in abundance" + ) + synset = converter._parse_data_line(line) + assert synset is not None + markers = {w.lemma: w.syntactic_marker for w in synset.words} + assert markers == {"abounding": None, "galore": "ip"} + + def test_all_marker_variants_in_data_file(self, converter, tmp_path): + """Parse a data.adj file exercising each marker type.""" + content = ( + "00020141 00 s 02 outback(a) 0 remote 0 000 | inaccessible\n" + "00019769 00 s 02 handy 0 ready_to_hand(p) 0 000 | easy to reach\n" + "00014377 00 s 02 abounding 0 galore(ip) 0 000 | in abundance\n" + "00001740 00 a 01 able 0 000 | having the means\n" + ) + data_file = tmp_path / "data.adj" + data_file.write_text(content, encoding="utf-8") + + synsets = converter.parse_data_file(data_file, "a") + converter.parse_data_file( + data_file, "s" + ) + # All four synsets survive parsing. + assert len(synsets) == 4 + + markers = {w.lemma: w.syntactic_marker for synset in synsets for w in synset.words} + assert markers["outback"] == "a" + assert markers["ready_to_hand"] == "p" + assert markers["galore"] == "ip" + # Words without a marker carry None. + assert markers["remote"] is None + assert markers["handy"] is None + assert markers["able"] is None + + def test_marker_survives_json_round_trip(self, converter): + """The syntactic_marker field round-trips through JSON serialization.""" + line = "00020141 00 s 01 outback(a) 0 000 | inaccessible" + synset = converter._parse_data_line(line) + assert synset is not None + + dumped = synset.model_dump_json() + restored = Synset.model_validate_json(dumped) + assert restored.words[0].lemma == "outback" + assert restored.words[0].syntactic_marker == "a" + + def test_non_adjective_words_have_no_marker(self, converter): + """Nouns and verbs never carry a syntactic marker.""" + line = "00001740 29 v 01 breathe 0 000 | take air" + synset = converter._parse_data_line(line) + assert synset is not None + assert synset.words[0].syntactic_marker is None + + class TestWordNetConverterFunctions: """Test WordNet converter module functions.""" diff --git a/tests/test_wordnet/test_models.py b/tests/test_wordnet/test_models.py index fe9998e..8b35d47 100644 --- a/tests/test_wordnet/test_models.py +++ b/tests/test_wordnet/test_models.py @@ -56,6 +56,20 @@ def test_word_lex_id_validation(self): with pytest.raises(ValidationError): Word(lemma="dog", lex_id=16) + def test_word_syntactic_marker(self): + """Test the adjective syntactic-position marker field (issue #8).""" + # Defaults to None for words without a marker. + assert Word(lemma="dog", lex_id=0).syntactic_marker is None + + # Accepts each valid AdjPosition value. + for marker in ("p", "a", "ip"): + word = Word(lemma="able", lex_id=0, syntactic_marker=marker) + assert word.syntactic_marker == marker + + # Rejects values outside the AdjPosition literal. + with pytest.raises(ValidationError): + Word(lemma="able", lex_id=0, syntactic_marker="x") + class TestPointer: """Test Pointer model."""