citegraph 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,143 @@
1
+ """Occurrence-based corrections bound to the works snapshot they describe."""
2
+ from __future__ import annotations
3
+
4
+ import csv
5
+ import json
6
+ from pathlib import Path
7
+
8
+ import pandas as pd
9
+
10
+ from citegraph.io import OutLayout, fingerprint, frame_fingerprint, read_json, write_json
11
+
12
+
13
+ def _redirects(path: Path) -> dict:
14
+ registry = OutLayout(Path(path).parent).work_identity_json
15
+ return read_json(registry).get('redirects', {}) if registry.exists() else {}
16
+
17
+
18
+ def _occurrence_evidence(rows: list[dict], works: pd.DataFrame) -> dict[str, str]:
19
+ from citegraph.authors import _row_authors
20
+
21
+ evidence = {}
22
+ for row in rows:
23
+ for side in ('left', 'right'):
24
+ wid, position = str(row[f'{side}_record_id']), int(row[f'{side}_position'])
25
+ work = works.loc[wid]
26
+ evidence[json.dumps([wid, position])] = fingerprint({
27
+ 'raw_author': _row_authors(work)[position],
28
+ 'Title': work.get('Title'), 'Year': work.get('Year'),
29
+ })
30
+ return evidence
31
+
32
+
33
+ def validate_constraints(rows: list[dict], coordinates: set[tuple[str, int]]) -> list[dict]:
34
+ result = []
35
+ for number, row in enumerate(rows, 2):
36
+ row = dict(row)
37
+ try:
38
+ if row['action'] not in {'merge', 'separate'} or not str(row['reason']).strip():
39
+ raise ValueError('action must be merge/separate and reason must be nonempty')
40
+ for side in ('left', 'right'):
41
+ value = str(row[f'{side}_position'])
42
+ if not value.isdigit():
43
+ raise ValueError('position must be a nonnegative integer')
44
+ row[f'{side}_position'] = int(value)
45
+ coordinate = (str(row[f'{side}_record_id']), int(value))
46
+ if coordinate not in coordinates:
47
+ raise ValueError(f'unknown author occurrence {coordinate}')
48
+ except (KeyError, ValueError) as exc:
49
+ raise ValueError(f'Author override row {number}: {row}: {exc}') from exc
50
+ result.append(row)
51
+ return result
52
+
53
+
54
+ def load_author_constraints(path: Path, *, works: pd.DataFrame, metadata_path: Path) -> list[dict]:
55
+ from citegraph.authors import _collect_occurrences
56
+
57
+ path, metadata_path = Path(path), Path(metadata_path)
58
+ if not path.exists():
59
+ return []
60
+ with path.open(newline='', encoding='utf-8') as stream:
61
+ reader = csv.DictReader(stream)
62
+ required = ['action', 'left_record_id', 'left_position', 'right_record_id',
63
+ 'right_position', 'reason']
64
+ if reader.fieldnames != required:
65
+ raise ValueError(f'Author override columns must be {required}; got {reader.fieldnames}')
66
+ rows = list(reader)
67
+ from citegraph.identity import resolve_redirect
68
+
69
+ redirects = _redirects(path)
70
+ for row in rows:
71
+ for side in ('left', 'right'):
72
+ row[f'{side}_record_id'] = resolve_redirect(row[f'{side}_record_id'], redirects)
73
+ current = frame_fingerprint(works.rename_axis('id'))
74
+ coordinates = {(o.record_id, o.position) for o in _collect_occurrences(
75
+ works=works, enriched_works=None)}
76
+ rows = validate_constraints(rows, coordinates)
77
+ if metadata_path.exists():
78
+ binding = json.loads(metadata_path.read_text())
79
+ if binding.get('schema_version') == 2:
80
+ stored = binding.get('occurrences')
81
+ if not isinstance(stored, dict):
82
+ raise ValueError('Invalid author override occurrence binding; restore metadata')
83
+ previous = {}
84
+ for key, value in stored.items():
85
+ wid, position = json.loads(key)
86
+ key = json.dumps([resolve_redirect(wid, redirects), position])
87
+ if key in previous and previous[key] != value:
88
+ raise ValueError(f'Author override occurrence became ambiguous: {key}')
89
+ previous[key] = value
90
+ changed = [key for key, value in _occurrence_evidence(rows, works).items()
91
+ if key in previous and previous[key] != value]
92
+ if changed:
93
+ raise ValueError(f'Author override snapshot changed at occurrences: {changed}. '
94
+ f'Review and explicitly rebind by removing {metadata_path}.')
95
+ elif binding.get('schema_version') != 1 or binding.get('works_fingerprint') != current:
96
+ raise ValueError(f'Author override snapshot changed to {current}; affected rows: {rows}. '
97
+ f'Review these occurrences and explicitly rebind by removing {metadata_path}.')
98
+ return rows
99
+
100
+
101
+ def bind_author_constraints(path: Path, *, works: pd.DataFrame, metadata_path: Path) -> None:
102
+ """Bind only after the caller successfully validates normalization and aliases."""
103
+ if Path(path).exists():
104
+ rows = load_author_constraints(path, works=works, metadata_path=metadata_path)
105
+ write_json(metadata_path, {'schema_version': 2, 'occurrences': _occurrence_evidence(rows, works),
106
+ 'works_fingerprint': frame_fingerprint(works.rename_axis('id'))})
107
+
108
+
109
+ def prepare_constraints(occurrences: list, rows: list[dict]) -> list[list]:
110
+ """Seed forced groups and attach expanded cannot-links before any automatic union."""
111
+ by_key = {(o.record_id, o.position): o for o in occurrences}
112
+ rows = validate_constraints(rows, set(by_key))
113
+ parent = {key: key for key in by_key}
114
+
115
+ def root(key):
116
+ while parent[key] != key:
117
+ parent[key] = parent[parent[key]]
118
+ key = parent[key]
119
+ return key
120
+
121
+ def pair(row):
122
+ return [(row[f'{side}_record_id'], row[f'{side}_position']) for side in ('left', 'right')]
123
+
124
+ for row in rows:
125
+ if row['action'] == 'merge':
126
+ left, right = pair(row)
127
+ parent[root(right)] = root(left)
128
+ groups = {}
129
+ for key, occurrence in by_key.items():
130
+ groups.setdefault(root(key), []).append(occurrence)
131
+ for group in groups.values():
132
+ if len({o.orcid for o in group if o.orcid}) > 1:
133
+ raise ValueError(f'Author merge has conflicting ORCIDs: {[(o.record_id, o.position) for o in group]}')
134
+ for row in rows:
135
+ if row['action'] == 'separate':
136
+ left, right = (root(key) for key in pair(row))
137
+ if left == right:
138
+ raise ValueError(f'Author override contradiction through merge chain: {row}')
139
+ for own, other in ((left, right), (right, left)):
140
+ forbidden = {(o.record_id, o.position) for o in groups[other]}
141
+ for occurrence in groups[own]:
142
+ occurrence.cannot_link.update(forbidden)
143
+ return list(groups.values())