igerasimov commited on
Commit
413d5a1
·
1 Parent(s): 0f2ecac

MVP Milestone 3

Browse files
IMPLEMENTATION_PLAN.md CHANGED
@@ -609,7 +609,7 @@ Included requirements: `PROJECT_SPEC.md / Dataset Specification / Article Datase
609
  Tasks:
610
 
611
  - Implement article model preserving exact source field names.
612
- - Validate required keys, field types, non-empty strings, and DOI uniqueness.
613
  - Report invalid records without modifying source records.
614
  - Add valid and invalid article fixtures.
615
 
@@ -619,14 +619,14 @@ Tests:
619
 
620
  - Valid article loading.
621
  - Missing required fields.
622
- - Empty DOI/title/abstract.
623
- - Non-integer Year.
624
  - Duplicate DOI.
625
  - Source values preserved exactly.
626
 
627
  Acceptance criteria:
628
 
629
- - Current full-data smoke test confirms `data/articles.json` validates with 468 unique DOI records.
630
  - Invalid fixtures produce explicit errors.
631
  - No normalization or rewriting occurs.
632
 
 
609
  Tasks:
610
 
611
  - Implement article model preserving exact source field names.
612
+ - Validate required keys, strict field types, non-empty DOI and Title strings, and DOI uniqueness. `Abstract` is required and must be a string, but may be empty.
613
  - Report invalid records without modifying source records.
614
  - Add valid and invalid article fixtures.
615
 
 
619
 
620
  - Valid article loading.
621
  - Missing required fields.
622
+ - Empty DOI and Title. Empty `Abstract` is allowed and must be preserved exactly.
623
+ - Non-integer Year, including Boolean values.
624
  - Duplicate DOI.
625
  - Source values preserved exactly.
626
 
627
  Acceptance criteria:
628
 
629
+ - Current full-data smoke test confirms `data/articles.json` contains 468 source records, 467 records with valid non-empty unique DOI values, exactly one invalid record at index 467 due to empty DOI, and 14 otherwise valid records with empty `Abstract` values.
630
  - Invalid fixtures produce explicit errors.
631
  - No normalization or rewriting occurs.
632
 
MVP_PROGRESS.md CHANGED
@@ -100,3 +100,84 @@ Deviations from approved plan:
100
  Remaining risks or assumptions:
101
  - The loader remains fully dynamic and does not hard-code vocabulary names, UUIDs, paths, branches, or record counts, but full-data smoke tests intentionally assert current-file counts.
102
  - Future approved vocabulary rules would be needed before accepting any UUID-less non-root hierarchy node.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  Remaining risks or assumptions:
101
  - The loader remains fully dynamic and does not hard-code vocabulary names, UUIDs, paths, branches, or record counts, but full-data smoke tests intentionally assert current-file counts.
102
  - Future approved vocabulary rules would be needed before accepting any UUID-less non-root hierarchy node.
103
+
104
+ ## Milestone 3: Article Loading and Validation
105
+
106
+ Status: completed
107
+
108
+ Scope completed:
109
+ - Deterministic loader for `data/articles.json`
110
+ - Strict source article model preserving exact serialized fields: `DOI`, `Title`, `Year`, and `Abstract`
111
+ - Read/decode, individual record validation, duplicate DOI detection, and aggregate result return paths
112
+ - Record-level validation errors with source index, code, field, message, and DOI when available
113
+ - Valid and invalid article fixtures
114
+ - Full-data smoke test for the current `data/articles.json`
115
+
116
+ Implemented article model and loader APIs:
117
+ - `ArticleRecord` in `src/gcmd_classifier/models.py`
118
+ - `ArticleValidationIssue` and `ArticleLoadResult` in `src/gcmd_classifier/models.py`
119
+ - `read_article_json`, `validate_article_record`, `validate_article_records`, and `load_articles` in `src/gcmd_classifier/articles/loader.py`
120
+
121
+ Validation and error-collection policy:
122
+ - File-level conditions raise typed exceptions: missing file, invalid JSON, and top-level JSON value that is not a list.
123
+ - Record-level conditions are collected in `ArticleLoadResult.errors` while all remaining records continue to be validated.
124
+ - Duplicate DOI records are reported with `DUPLICATE_DOI` and excluded from `ArticleLoadResult.articles` so returned records have unique DOI values.
125
+
126
+ Extra-field policy:
127
+ - Unexpected fields are rejected for the MVP with `UNEXPECTED_FIELD` because neither `PROJECT_SPEC.md` nor `AGENTS.md` defines an allow-list extension policy for source article records.
128
+
129
+ Exact-preservation protections:
130
+ - Successful `ArticleRecord` values are returned unchanged.
131
+ - DOI values are not normalized or replaced.
132
+ - Titles and abstracts are not stripped, lowercased, rewritten, summarized, or supplemented.
133
+ - Empty `Abstract` values are valid and preserved exactly.
134
+ - Whitespace-only strings are preserved; non-empty checks do not trim source values.
135
+ - `Year` must be a strict integer and Boolean values are rejected.
136
+
137
+ Current full-data findings:
138
+ - `data/articles.json` contains 468 source records.
139
+ - 467 records have valid non-empty unique DOI values.
140
+ - Exactly one record, index 467, is invalid because `DOI` is empty.
141
+ - 14 records have empty `Abstract` values and otherwise load successfully.
142
+
143
+ Error cases covered:
144
+ - File not found
145
+ - Invalid JSON
146
+ - Top-level JSON value not a list
147
+ - Article entry not an object
148
+ - Missing `DOI`, `Title`, `Year`, or `Abstract`
149
+ - Empty `DOI` and `Title`
150
+ - Non-string `DOI`, `Title`, or `Abstract`
151
+ - String, floating-point, and Boolean `Year`
152
+ - Null field values
153
+ - Duplicate DOI
154
+ - Unexpected extra fields
155
+ - Multiple invalid records reported in one aggregate result
156
+
157
+ Verification:
158
+ - `python -m ruff check .` passed: `All checks passed!`
159
+ - `python -m ruff format --check .` passed: `11 files already formatted`
160
+ - `python -m pytest` passed: `51 passed in 0.18s`
161
+
162
+ Out of scope, not started:
163
+ - Model prompts
164
+ - Model-provider integration
165
+ - Topic routing
166
+ - Term routing
167
+ - Variable-level classification
168
+ - Persistence
169
+ - Caching
170
+ - Batch processing
171
+ - UI and root `app.py`
172
+
173
+ Source-file status:
174
+ - `data/articles.json` unchanged by this milestone.
175
+ - `data/gcmd_hierarchy.json` unchanged by this milestone.
176
+ - `prototype/app_hf_poc.py` unchanged by this milestone.
177
+
178
+ Deviations from approved plan:
179
+ - `IMPLEMENTATION_PLAN.md` was revised to reflect the clarified Milestone 3 rule that empty `Abstract` values are valid.
180
+
181
+ Remaining risks or assumptions:
182
+ - The current source file remains partially invalid until the empty DOI at index 467 is corrected.
183
+ - Whitespace-only DOI or Title values are non-empty and therefore preserved as valid under the explicit no-trimming rule; this can be tightened later only with an approved source-data rule.
src/gcmd_classifier/articles/__init__.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Article loading and validation for source journal records."""
2
+
3
+ from gcmd_classifier.articles.loader import (
4
+ load_articles,
5
+ read_article_json,
6
+ validate_article_record,
7
+ validate_article_records,
8
+ )
9
+
10
+ __all__ = [
11
+ "load_articles",
12
+ "read_article_json",
13
+ "validate_article_record",
14
+ "validate_article_records",
15
+ ]
src/gcmd_classifier/articles/loader.py ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deterministic loading and validation for source article records."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections import Counter
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from pydantic import ValidationError
11
+
12
+ from gcmd_classifier.errors import (
13
+ ArticleFileNotFoundError,
14
+ ArticleJSONDecodeError,
15
+ ArticleTopLevelTypeError,
16
+ )
17
+ from gcmd_classifier.models import ArticleLoadResult, ArticleRecord, ArticleValidationIssue
18
+
19
+ REQUIRED_FIELDS = ("DOI", "Title", "Year", "Abstract")
20
+ _STRING_FIELDS = ("DOI", "Title", "Abstract")
21
+ _NON_EMPTY_STRING_FIELDS = ("DOI", "Title")
22
+
23
+
24
+ def read_article_json(path: str | Path) -> Any:
25
+ """Read and decode an article JSON file without validating record contents."""
26
+ article_path = Path(path)
27
+ try:
28
+ raw_bytes = article_path.read_bytes()
29
+ except FileNotFoundError as exc:
30
+ raise ArticleFileNotFoundError(f"Article file not found: {article_path}") from exc
31
+ try:
32
+ return json.loads(raw_bytes)
33
+ except json.JSONDecodeError as exc:
34
+ raise ArticleJSONDecodeError(f"Invalid JSON in article file: {article_path}") from exc
35
+
36
+
37
+ def validate_article_record(
38
+ raw_record: Any, index: int
39
+ ) -> tuple[ArticleRecord | None, tuple[ArticleValidationIssue, ...]]:
40
+ """Validate one article record, preserving source values exactly on success."""
41
+ issues: list[ArticleValidationIssue] = []
42
+ if not isinstance(raw_record, dict):
43
+ return None, (
44
+ ArticleValidationIssue(
45
+ index=index,
46
+ code="ARTICLE_NOT_OBJECT",
47
+ message="Article entry must be a JSON object.",
48
+ ),
49
+ )
50
+
51
+ for field in REQUIRED_FIELDS:
52
+ if field not in raw_record:
53
+ issues.append(
54
+ ArticleValidationIssue(
55
+ index=index,
56
+ code="MISSING_REQUIRED_FIELD",
57
+ field=field,
58
+ message=f"Article record is missing required field {field!r}.",
59
+ DOI=_safe_doi(raw_record),
60
+ )
61
+ )
62
+
63
+ for field in _STRING_FIELDS:
64
+ if field in raw_record and not isinstance(raw_record[field], str):
65
+ issues.append(
66
+ ArticleValidationIssue(
67
+ index=index,
68
+ code="INVALID_FIELD_TYPE",
69
+ field=field,
70
+ message=f"Article field {field!r} must be a string.",
71
+ DOI=_safe_doi(raw_record),
72
+ )
73
+ )
74
+
75
+ for field in _NON_EMPTY_STRING_FIELDS:
76
+ if field in raw_record and isinstance(raw_record[field], str) and not raw_record[field]:
77
+ issues.append(
78
+ ArticleValidationIssue(
79
+ index=index,
80
+ code="EMPTY_REQUIRED_STRING",
81
+ field=field,
82
+ message=f"Article field {field!r} must be a non-empty string.",
83
+ DOI=_safe_doi(raw_record),
84
+ )
85
+ )
86
+
87
+ if "Year" in raw_record:
88
+ year = raw_record["Year"]
89
+ if isinstance(year, bool) or not isinstance(year, int):
90
+ issues.append(
91
+ ArticleValidationIssue(
92
+ index=index,
93
+ code="INVALID_FIELD_TYPE",
94
+ field="Year",
95
+ message="Article field 'Year' must be a strict integer and not a Boolean.",
96
+ DOI=_safe_doi(raw_record),
97
+ )
98
+ )
99
+
100
+ unexpected_fields = tuple(field for field in raw_record if field not in REQUIRED_FIELDS)
101
+ for field in unexpected_fields:
102
+ issues.append(
103
+ ArticleValidationIssue(
104
+ index=index,
105
+ code="UNEXPECTED_FIELD",
106
+ field=field,
107
+ message=f"Unexpected article field {field!r} is not allowed in the MVP.",
108
+ DOI=_safe_doi(raw_record),
109
+ )
110
+ )
111
+
112
+ if issues:
113
+ return None, tuple(issues)
114
+
115
+ try:
116
+ return ArticleRecord.model_validate(raw_record), ()
117
+ except ValidationError as exc:
118
+ return None, tuple(_issues_from_pydantic_error(exc, raw_record=raw_record, index=index))
119
+
120
+
121
+ def validate_article_records(raw_records: Any) -> ArticleLoadResult:
122
+ """Validate a top-level article list and collect all record-level validation errors."""
123
+ if not isinstance(raw_records, list):
124
+ raise ArticleTopLevelTypeError("Article source JSON must be a top-level list.")
125
+
126
+ valid_candidates: list[tuple[int, ArticleRecord]] = []
127
+ errors: list[ArticleValidationIssue] = []
128
+ for index, raw_record in enumerate(raw_records):
129
+ article, issues = validate_article_record(raw_record, index)
130
+ errors.extend(issues)
131
+ if article is not None:
132
+ valid_candidates.append((index, article))
133
+
134
+ doi_counts = Counter(article.DOI for _, article in valid_candidates)
135
+ duplicate_dois = {doi for doi, count in doi_counts.items() if count > 1}
136
+ if duplicate_dois:
137
+ unique_candidates: list[tuple[int, ArticleRecord]] = []
138
+ for index, article in valid_candidates:
139
+ if article.DOI in duplicate_dois:
140
+ errors.append(
141
+ ArticleValidationIssue(
142
+ index=index,
143
+ code="DUPLICATE_DOI",
144
+ field="DOI",
145
+ message=f"Duplicate DOI value {article.DOI!r}.",
146
+ DOI=article.DOI,
147
+ )
148
+ )
149
+ else:
150
+ unique_candidates.append((index, article))
151
+ valid_candidates = unique_candidates
152
+
153
+ return ArticleLoadResult(
154
+ articles=tuple(article for _, article in valid_candidates),
155
+ errors=tuple(errors),
156
+ source_count=len(raw_records),
157
+ )
158
+
159
+
160
+ def load_articles(path: str | Path) -> ArticleLoadResult:
161
+ """Read and validate article records from a JSON file."""
162
+ return validate_article_records(read_article_json(path))
163
+
164
+
165
+ def _safe_doi(raw_record: dict[str, Any]) -> str | None:
166
+ value = raw_record.get("DOI")
167
+ return value if isinstance(value, str) else None
168
+
169
+
170
+ def _issues_from_pydantic_error(
171
+ exc: ValidationError, *, raw_record: dict[str, Any], index: int
172
+ ) -> tuple[ArticleValidationIssue, ...]:
173
+ issues: list[ArticleValidationIssue] = []
174
+ for error in exc.errors():
175
+ location = error.get("loc", ())
176
+ field = str(location[0]) if location else None
177
+ issues.append(
178
+ ArticleValidationIssue(
179
+ index=index,
180
+ code="MODEL_VALIDATION_ERROR",
181
+ field=field,
182
+ message=str(error.get("msg", "Article model validation failed.")),
183
+ DOI=_safe_doi(raw_record),
184
+ )
185
+ )
186
+ return tuple(issues)
src/gcmd_classifier/errors.py CHANGED
@@ -27,3 +27,19 @@ class InvalidHierarchyTransitionError(VocabularyError):
27
 
28
  class VocabularyLookupError(VocabularyError):
29
  """Raised when a requested vocabulary concept or relationship is missing."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
 
28
  class VocabularyLookupError(VocabularyError):
29
  """Raised when a requested vocabulary concept or relationship is missing."""
30
+
31
+
32
+ class ArticleError(GCMDClassifierError):
33
+ """Base exception for article loading and validation errors."""
34
+
35
+
36
+ class ArticleFileNotFoundError(ArticleError):
37
+ """Raised when an article source file does not exist."""
38
+
39
+
40
+ class ArticleJSONDecodeError(ArticleError):
41
+ """Raised when an article source file contains invalid JSON."""
42
+
43
+
44
+ class ArticleTopLevelTypeError(ArticleError):
45
+ """Raised when an article source file is not a top-level JSON list."""
src/gcmd_classifier/models.py CHANGED
@@ -40,3 +40,46 @@ class CanonicalConceptRecord(BaseModel):
40
  assignable: bool
41
  definition: str | None = None
42
  vocabulary_version: str
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  assignable: bool
41
  definition: str | None = None
42
  vocabulary_version: str
43
+
44
+
45
+ class ArticleRecord(BaseModel):
46
+ """Validated source article record with exact serialized field names."""
47
+
48
+ model_config = ConfigDict(extra="forbid", frozen=True, strict=True)
49
+
50
+ DOI: str
51
+ Title: str
52
+ Year: int
53
+ Abstract: str
54
+
55
+
56
+ class ArticleValidationIssue(BaseModel):
57
+ """Structured validation issue for one article source record."""
58
+
59
+ model_config = ConfigDict(frozen=True)
60
+
61
+ index: int | None
62
+ code: str
63
+ message: str
64
+ field: str | None = None
65
+ DOI: str | None = None
66
+
67
+
68
+ class ArticleLoadResult(BaseModel):
69
+ """Aggregate article loading result preserving valid records and all issues."""
70
+
71
+ model_config = ConfigDict(frozen=True)
72
+
73
+ articles: tuple[ArticleRecord, ...]
74
+ errors: tuple[ArticleValidationIssue, ...] = Field(default_factory=tuple)
75
+ source_count: int
76
+
77
+ @property
78
+ def valid_count(self) -> int:
79
+ """Number of records that passed validation and duplicate checks."""
80
+ return len(self.articles)
81
+
82
+ @property
83
+ def has_errors(self) -> bool:
84
+ """Whether any source records failed validation."""
85
+ return bool(self.errors)
tests/fixtures/articles_invalid.json ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "Title": "Missing DOI",
4
+ "Year": 2024,
5
+ "Abstract": "A record without DOI."
6
+ },
7
+ {
8
+ "DOI": "10.1000/MISSINGTITLE",
9
+ "Year": 2024,
10
+ "Abstract": "A record without title."
11
+ },
12
+ {
13
+ "DOI": "10.1000/MISSINGYEAR",
14
+ "Title": "Missing year",
15
+ "Abstract": "A record without year."
16
+ },
17
+ {
18
+ "DOI": "10.1000/MISSINGABSTRACT",
19
+ "Title": "Missing abstract",
20
+ "Year": 2024
21
+ },
22
+ {
23
+ "DOI": "",
24
+ "Title": "Empty DOI",
25
+ "Year": 2024,
26
+ "Abstract": "Empty DOI should fail."
27
+ },
28
+ {
29
+ "DOI": "10.1000/EMPTYTITLE",
30
+ "Title": "",
31
+ "Year": 2024,
32
+ "Abstract": "Empty title should fail."
33
+ },
34
+ {
35
+ "DOI": "10.1000/WHITESPACE",
36
+ "Title": " ",
37
+ "Year": 2024,
38
+ "Abstract": "Whitespace title is preserved and currently valid."
39
+ },
40
+ {
41
+ "DOI": 123,
42
+ "Title": "Non-string DOI",
43
+ "Year": 2024,
44
+ "Abstract": "DOI must be a string."
45
+ },
46
+ {
47
+ "DOI": "10.1000/NONSTRINGTITLE",
48
+ "Title": ["bad"],
49
+ "Year": 2024,
50
+ "Abstract": "Title must be a string."
51
+ },
52
+ {
53
+ "DOI": "10.1000/NONSTRINGABSTRACT",
54
+ "Title": "Non-string abstract",
55
+ "Year": 2024,
56
+ "Abstract": 42
57
+ },
58
+ {
59
+ "DOI": "10.1000/STRINGYEAR",
60
+ "Title": "String year",
61
+ "Year": "2024",
62
+ "Abstract": "Year must not be a string."
63
+ },
64
+ {
65
+ "DOI": "10.1000/FLOATYEAR",
66
+ "Title": "Float year",
67
+ "Year": 2024.0,
68
+ "Abstract": "Year must not be a float."
69
+ },
70
+ {
71
+ "DOI": "10.1000/BOOLYEAR",
72
+ "Title": "Boolean year",
73
+ "Year": true,
74
+ "Abstract": "Year must not be Boolean."
75
+ },
76
+ {
77
+ "DOI": null,
78
+ "Title": "Null DOI",
79
+ "Year": 2024,
80
+ "Abstract": "Null DOI should fail."
81
+ },
82
+ "not an object",
83
+ {
84
+ "DOI": "10.1000/DUPLICATE",
85
+ "Title": "Duplicate one",
86
+ "Year": 2024,
87
+ "Abstract": "First duplicate."
88
+ },
89
+ {
90
+ "DOI": "10.1000/DUPLICATE",
91
+ "Title": "Duplicate two",
92
+ "Year": 2025,
93
+ "Abstract": "Second duplicate."
94
+ },
95
+ {
96
+ "DOI": "10.1000/EXTRA",
97
+ "Title": "Unexpected field",
98
+ "Year": 2025,
99
+ "Abstract": "Extra fields are rejected for MVP.",
100
+ "Journal": "Example"
101
+ }
102
+ ]
tests/fixtures/articles_valid.json ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "DOI": "10.0001/ATMOS.TEST",
4
+ "Title": " High-Resolution Rainfall Trends over Mountain Basins: A 20-Year Study ",
5
+ "Year": 2024,
6
+ "Abstract": "Precipitation intensity, snowpack, and runoff timing are evaluated using station and satellite observations."
7
+ },
8
+ {
9
+ "DOI": "10.0002/MULTI.TEST",
10
+ "Title": "Atmosphere-ocean coupling during coastal marine heatwaves",
11
+ "Year": 2025,
12
+ "Abstract": "The study links sea surface temperature anomalies, boundary-layer humidity, and plankton bloom timing."
13
+ },
14
+ {
15
+ "DOI": "10.0003/TITLEONLY.TEST",
16
+ "Title": "Editorial: New instrumentation for environmental archives?",
17
+ "Year": 2023,
18
+ "Abstract": ""
19
+ }
20
+ ]
tests/test_articles.py ADDED
@@ -0,0 +1,246 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ from gcmd_classifier.articles import (
9
+ load_articles,
10
+ read_article_json,
11
+ validate_article_record,
12
+ validate_article_records,
13
+ )
14
+ from gcmd_classifier.errors import (
15
+ ArticleFileNotFoundError,
16
+ ArticleJSONDecodeError,
17
+ ArticleTopLevelTypeError,
18
+ )
19
+ from gcmd_classifier.models import ArticleRecord
20
+
21
+ VALID_FIXTURE = Path("tests/fixtures/articles_valid.json")
22
+ INVALID_FIXTURE = Path("tests/fixtures/articles_invalid.json")
23
+ FULL_ARTICLES_PATH = Path("data/articles.json")
24
+
25
+
26
+ def test_valid_article_loading() -> None:
27
+ result = load_articles(VALID_FIXTURE)
28
+
29
+ assert result.source_count == 3
30
+ assert result.valid_count == 3
31
+ assert result.errors == ()
32
+ assert all(isinstance(article, ArticleRecord) for article in result.articles)
33
+
34
+
35
+ def test_preserves_source_order() -> None:
36
+ result = load_articles(VALID_FIXTURE)
37
+
38
+ assert [article.DOI for article in result.articles] == [
39
+ "10.0001/ATMOS.TEST",
40
+ "10.0002/MULTI.TEST",
41
+ "10.0003/TITLEONLY.TEST",
42
+ ]
43
+
44
+
45
+ def test_exact_source_value_preservation() -> None:
46
+ raw = json.loads(VALID_FIXTURE.read_text())
47
+ result = load_articles(VALID_FIXTURE)
48
+ first = result.articles[0]
49
+
50
+ assert first.DOI == raw[0]["DOI"]
51
+ assert first.Title == raw[0]["Title"]
52
+ assert first.Year == raw[0]["Year"]
53
+ assert first.Abstract == raw[0]["Abstract"]
54
+ assert first.Title.startswith(" ")
55
+ assert first.Title.endswith(" ")
56
+
57
+
58
+ def test_empty_abstract_is_valid_and_preserved() -> None:
59
+ result = load_articles(VALID_FIXTURE)
60
+ article = result.articles[2]
61
+
62
+ assert article.Abstract == ""
63
+ assert article.Title == "Editorial: New instrumentation for environmental archives?"
64
+
65
+
66
+ def test_exact_serialization_field_names() -> None:
67
+ article = load_articles(VALID_FIXTURE).articles[0]
68
+
69
+ assert article.model_dump() == {
70
+ "DOI": "10.0001/ATMOS.TEST",
71
+ "Title": " High-Resolution Rainfall Trends over Mountain Basins: A 20-Year Study ",
72
+ "Year": 2024,
73
+ "Abstract": "Precipitation intensity, snowpack, and runoff timing are evaluated using "
74
+ "station and satellite observations.",
75
+ }
76
+
77
+
78
+ def test_invalid_fixture_reports_multiple_record_errors() -> None:
79
+ result = load_articles(INVALID_FIXTURE)
80
+ codes = [error.code for error in result.errors]
81
+
82
+ assert result.source_count == 18
83
+ assert result.has_errors is True
84
+ assert "MISSING_REQUIRED_FIELD" in codes
85
+ assert "EMPTY_REQUIRED_STRING" in codes
86
+ assert "INVALID_FIELD_TYPE" in codes
87
+ assert "ARTICLE_NOT_OBJECT" in codes
88
+ assert "DUPLICATE_DOI" in codes
89
+ assert "UNEXPECTED_FIELD" in codes
90
+ assert len(result.errors) >= 17
91
+
92
+
93
+ @pytest.mark.parametrize(
94
+ ("record", "field"),
95
+ [
96
+ ({"Title": "T", "Year": 2024, "Abstract": "A"}, "DOI"),
97
+ ({"DOI": "10.x", "Year": 2024, "Abstract": "A"}, "Title"),
98
+ ({"DOI": "10.x", "Title": "T", "Abstract": "A"}, "Year"),
99
+ ({"DOI": "10.x", "Title": "T", "Year": 2024}, "Abstract"),
100
+ ],
101
+ )
102
+ def test_missing_required_fields(record: dict, field: str) -> None:
103
+ article, errors = validate_article_record(record, index=7)
104
+
105
+ assert article is None
106
+ assert any(error.code == "MISSING_REQUIRED_FIELD" and error.field == field for error in errors)
107
+ assert all(error.index == 7 for error in errors)
108
+
109
+
110
+ @pytest.mark.parametrize(
111
+ ("record", "field"),
112
+ [
113
+ ({"DOI": "", "Title": "T", "Year": 2024, "Abstract": "A"}, "DOI"),
114
+ ({"DOI": "10.x", "Title": "", "Year": 2024, "Abstract": "A"}, "Title"),
115
+ ],
116
+ )
117
+ def test_empty_required_strings(record: dict, field: str) -> None:
118
+ article, errors = validate_article_record(record, index=1)
119
+
120
+ assert article is None
121
+ assert any(error.code == "EMPTY_REQUIRED_STRING" and error.field == field for error in errors)
122
+
123
+
124
+ def test_whitespace_only_title_is_preserved_not_normalized() -> None:
125
+ record = {"DOI": "10.x/space", "Title": " ", "Year": 2024, "Abstract": "A"}
126
+
127
+ article, errors = validate_article_record(record, index=0)
128
+
129
+ assert errors == ()
130
+ assert article is not None
131
+ assert article.Title == " "
132
+
133
+
134
+ @pytest.mark.parametrize(
135
+ ("field", "value"),
136
+ [
137
+ ("DOI", 123),
138
+ ("Title", ["bad"]),
139
+ ("Abstract", 42),
140
+ ],
141
+ )
142
+ def test_non_string_fields_rejected(field: str, value: object) -> None:
143
+ record = {"DOI": "10.x", "Title": "T", "Year": 2024, "Abstract": "A"}
144
+ record[field] = value
145
+
146
+ article, errors = validate_article_record(record, index=2)
147
+
148
+ assert article is None
149
+ assert any(error.code == "INVALID_FIELD_TYPE" and error.field == field for error in errors)
150
+
151
+
152
+ @pytest.mark.parametrize("year", ["2024", 2024.0, True, False])
153
+ def test_invalid_year_types_rejected(year: object) -> None:
154
+ record = {"DOI": "10.x/year", "Title": "T", "Year": year, "Abstract": "A"}
155
+
156
+ article, errors = validate_article_record(record, index=3)
157
+
158
+ assert article is None
159
+ assert any(error.code == "INVALID_FIELD_TYPE" and error.field == "Year" for error in errors)
160
+
161
+
162
+ def test_null_field_values_rejected() -> None:
163
+ record = {"DOI": None, "Title": None, "Year": None, "Abstract": None}
164
+
165
+ article, errors = validate_article_record(record, index=4)
166
+
167
+ assert article is None
168
+ assert len([error for error in errors if error.code == "INVALID_FIELD_TYPE"]) == 4
169
+
170
+
171
+ def test_duplicate_doi_detection_excludes_duplicate_records_from_valid_records() -> None:
172
+ raw_records = [
173
+ {"DOI": "10.same", "Title": "One", "Year": 2024, "Abstract": "A"},
174
+ {"DOI": "10.same", "Title": "Two", "Year": 2025, "Abstract": "B"},
175
+ {"DOI": "10.unique", "Title": "Three", "Year": 2026, "Abstract": "C"},
176
+ ]
177
+
178
+ result = validate_article_records(raw_records)
179
+
180
+ assert [article.DOI for article in result.articles] == ["10.unique"]
181
+ assert [error.index for error in result.errors if error.code == "DUPLICATE_DOI"] == [0, 1]
182
+
183
+
184
+ def test_non_object_article_entry() -> None:
185
+ result = validate_article_records(["bad"])
186
+
187
+ assert result.articles == ()
188
+ assert result.errors[0].code == "ARTICLE_NOT_OBJECT"
189
+ assert result.errors[0].index == 0
190
+
191
+
192
+ def test_top_level_non_list_json() -> None:
193
+ with pytest.raises(ArticleTopLevelTypeError):
194
+ validate_article_records({"DOI": "10.x"})
195
+
196
+
197
+ def test_invalid_json(tmp_path: Path) -> None:
198
+ path = tmp_path / "invalid.json"
199
+ path.write_text("[{bad json]")
200
+
201
+ with pytest.raises(ArticleJSONDecodeError):
202
+ read_article_json(path)
203
+
204
+
205
+ def test_file_not_found_behavior(tmp_path: Path) -> None:
206
+ with pytest.raises(ArticleFileNotFoundError):
207
+ load_articles(tmp_path / "missing.json")
208
+
209
+
210
+ def test_source_file_immutability() -> None:
211
+ before = FULL_ARTICLES_PATH.read_bytes()
212
+ load_articles(FULL_ARTICLES_PATH)
213
+ after = FULL_ARTICLES_PATH.read_bytes()
214
+
215
+ assert after == before
216
+
217
+
218
+ def test_full_data_smoke_current_articles_file() -> None:
219
+ raw_records = json.loads(FULL_ARTICLES_PATH.read_text())
220
+ result = load_articles(FULL_ARTICLES_PATH)
221
+
222
+ assert result.source_count == 468
223
+ assert len(raw_records) == 468
224
+ assert result.valid_count == 467
225
+ assert len({article.DOI for article in result.articles}) == 467
226
+ assert result.has_errors is True
227
+ assert len(result.errors) == 1
228
+ error = result.errors[0]
229
+ assert error.index == 467
230
+ assert error.field == "DOI"
231
+ assert error.code == "EMPTY_REQUIRED_STRING"
232
+
233
+ empty_abstract_indices = [
234
+ index for index, record in enumerate(raw_records) if record.get("Abstract") == ""
235
+ ]
236
+ assert len(empty_abstract_indices) == 14
237
+ assert all(
238
+ raw_records[index]["DOI"] in {article.DOI for article in result.articles}
239
+ for index in empty_abstract_indices
240
+ )
241
+
242
+ valid_raw_records = [record for index, record in enumerate(raw_records) if index != 467]
243
+ assert [article.DOI for article in result.articles] == [
244
+ record["DOI"] for record in valid_raw_records
245
+ ]
246
+ assert result.articles[0].model_dump() == valid_raw_records[0]