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.
citegraph/identity.py ADDED
@@ -0,0 +1,199 @@
1
+ """Persist slug identities using observed membership, without freezing clusters."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from collections import defaultdict
6
+
7
+ from citegraph.io import fingerprint, metadata_fingerprint
8
+
9
+
10
+ def reconcile(groups: dict[str, set[str]], previous: dict | None = None, *,
11
+ pinned: dict[str, str] | None = None) -> tuple[dict, dict, list]:
12
+ """Keep unambiguous continuations; retire splits and redirect merged IDs.
13
+
14
+ Missing entities remain reserved so removing/reintroducing input cannot give
15
+ an old annotation key to somebody else. Membership hashes are evidence only;
16
+ new entity IDs are always the supplied deterministic slugs (with suffixes).
17
+
18
+ ``pinned`` maps a group key to an ID a *user* asserted it continues — an
19
+ alias merge naming both spellings of one person. That is the strongest
20
+ identity evidence available, so a pin claims its ID even when the prior
21
+ group otherwise reads as a split: the child is not arbitrary, it is the
22
+ one the user pointed at. The split is still recorded, as
23
+ ``identity_split_resolved``, and the remaining children get fresh IDs.
24
+ Two groups can never claim the same pin.
25
+ """
26
+ previous = previous if previous is not None else {
27
+ 'schema_version': 1, 'entries': {}, 'reserved_ids': [], 'redirects': {}, 'history': []}
28
+ if (not isinstance(previous, dict) or previous.get('schema_version') != 1
29
+ or not isinstance(previous.get('entries'), dict)
30
+ or not isinstance(previous.get('redirects'), dict)
31
+ or not isinstance(previous.get('reserved_ids'), list)
32
+ or not isinstance(previous.get('history'), list)):
33
+ raise ValueError('Invalid identity registry; restore its last valid copy')
34
+ entries = previous['entries']
35
+ if any(not isinstance(key, str) or not isinstance(members, list)
36
+ or any(not isinstance(member, str) for member in members)
37
+ for key, members in entries.items()):
38
+ raise ValueError('Invalid identity membership; restore its last valid copy')
39
+ redirects = dict(previous['redirects'])
40
+ if any(not isinstance(k, str) or not isinstance(v, str) for k, v in redirects.items()):
41
+ raise ValueError('Invalid identity redirects')
42
+ for key in redirects:
43
+ resolve_redirect(key, redirects)
44
+ reserved = set(previous['reserved_ids']) | set(entries) | set(redirects) | set(redirects.values())
45
+ old_by_member: dict[str, set[str]] = defaultdict(set)
46
+ for old, members in entries.items():
47
+ for member in members:
48
+ old_by_member[member].add(old)
49
+ ancestors = {new: set().union(*(old_by_member[m] for m in members))
50
+ for new, members in groups.items()}
51
+ descendants: dict[str, set[str]] = defaultdict(set)
52
+ for new, old_ids in ancestors.items():
53
+ for old in old_ids:
54
+ descendants[old].add(new)
55
+ splits = {old for old, children in descendants.items() if len(children) > 1}
56
+ assigned: dict[str, str] = {}
57
+ # A user-curated merge outranks the split rule; honour it before anything
58
+ # else can reserve the ID. Pinned IDs stay in ``splits`` so no *other*
59
+ # child can pick them up as an ordinary continuation.
60
+ claimed: dict[str, str] = {}
61
+ for new in sorted(groups):
62
+ target = pinned.get(new) if pinned else None
63
+ if target is None or target in claimed:
64
+ continue
65
+ claimed[target] = new
66
+ assigned[new] = target
67
+ # Reserve all continuations before allocating any new collision suffix.
68
+ for new in sorted(groups):
69
+ if new in assigned:
70
+ continue
71
+ candidates = ancestors[new] - splits - set(claimed)
72
+ if candidates:
73
+ assigned[new] = new if new in candidates else min(candidates)
74
+ used = set(assigned.values())
75
+ for new in sorted(groups):
76
+ if new in assigned:
77
+ continue
78
+ candidate, suffix = new, 1
79
+ while candidate in reserved or candidate in used:
80
+ candidate = f'{new}-{suffix}'
81
+ suffix += 1
82
+ assigned[new] = candidate
83
+ used.add(candidate)
84
+ changes = []
85
+ retained = {old: list(members) for old, members in entries.items()
86
+ if old not in descendants}
87
+ for new, cid in assigned.items():
88
+ # Preserve disappeared members of a continuation for future restoration.
89
+ members = set(groups[new])
90
+ inherited = ancestors[new] - splits
91
+ if claimed.get(cid) == new and cid in entries:
92
+ inherited = inherited | {cid}
93
+ for old in inherited:
94
+ members.update(entries[old])
95
+ if old != cid:
96
+ redirects[old] = cid
97
+ changes.append({'decision': 'identity_merge', 'old_id': old, 'new_id': cid})
98
+ retained[cid] = sorted(members)
99
+ for old in sorted(splits):
100
+ entry = {'decision': 'identity_split', 'old_id': old,
101
+ 'new_ids': sorted(assigned[new] for new in descendants[old])}
102
+ if old in claimed:
103
+ # A user merge decided which child continues; say so in the audit
104
+ # rather than reporting the ID as retired.
105
+ entry['decision'] = 'identity_split_resolved'
106
+ entry['assigned_id'] = old
107
+ changes.append(entry)
108
+ # Redirects ending at a split remain retired, never choose a split child.
109
+ state = {'schema_version': 1, 'entries': retained,
110
+ 'reserved_ids': sorted(reserved | used), 'redirects': redirects,
111
+ 'history': previous['history'] + changes}
112
+ return assigned, state, changes
113
+
114
+
115
+ def resolve_redirect(identifier: str, redirects: dict[str, str]) -> str:
116
+ seen = set()
117
+ while identifier in redirects:
118
+ if identifier in seen:
119
+ raise ValueError('Invalid identity redirect cycle')
120
+ seen.add(identifier)
121
+ identifier = redirects[identifier]
122
+ return identifier
123
+
124
+
125
+ def occurrence_key(record_id: str, position: int, raw_author: str) -> str:
126
+ return json.dumps([str(record_id), int(position), raw_author], ensure_ascii=False)
127
+
128
+
129
+ def remap_author_registry(state: dict, work_redirects: dict) -> dict:
130
+ """Apply confirmed work redirects to author evidence, never split mappings."""
131
+ result = dict(state)
132
+ for section in ('algorithm', 'published'):
133
+ if section not in state:
134
+ continue
135
+ registry = dict(state[section])
136
+ entries = {}
137
+ for aid, members in registry['entries'].items():
138
+ entries[aid] = []
139
+ for member in members:
140
+ wid, position, raw = json.loads(member)
141
+ entries[aid].append(occurrence_key(resolve_redirect(wid, work_redirects), position, raw))
142
+ registry['entries'] = entries
143
+ result[section] = registry
144
+ return result
145
+
146
+
147
+ def work_groups(works, sources, citations, stats) -> dict[str, set[str]]:
148
+ groups = {str(wid): set() for wid in works.index}
149
+ metadata_groups: dict[str, set[str]] = defaultdict(set)
150
+ for row, cid in zip(sources.to_dict('records'), stats['source_cluster_ids'], strict=True):
151
+ signature = metadata_fingerprint(row)
152
+ groups[cid].add(fingerprint(['source', row['id'], signature]))
153
+ metadata_groups[signature].add(cid)
154
+ positions: dict[str, int] = defaultdict(int)
155
+ for row, cid in zip(citations.to_dict('records'), stats['citation_cluster_ids'], strict=True):
156
+ signature = metadata_fingerprint(row)
157
+ source = str(row['citing_id'])
158
+ groups[cid].add(fingerprint(['citation', source, positions[source], signature]))
159
+ positions[source] += 1
160
+ metadata_groups[signature].add(cid)
161
+ # An exact metadata observation is an anchor only if it names one cluster.
162
+ # This also bootstraps old works tables without inventing past decisions.
163
+ for signature, ids in metadata_groups.items():
164
+ if len(ids) == 1:
165
+ groups[next(iter(ids))].add('metadata:' + signature)
166
+ return groups
167
+
168
+
169
+ def seed_work_registry(works) -> dict:
170
+ groups = {str(wid): {'metadata:' + metadata_fingerprint(row.to_dict())}
171
+ for wid, row in works.iterrows()}
172
+ return reconcile(groups)[1]
173
+
174
+
175
+ def seed_author_registry(authors, citations, *, works=None, skipped=None) -> dict:
176
+ from citegraph.authors import _row_authors
177
+
178
+ if not authors.index.is_unique or citations.duplicated(['record_id', 'position']).any():
179
+ raise ValueError('Invalid prior author identity: duplicate IDs or occurrences')
180
+ groups = {str(aid): set() for aid in authors.index}
181
+ for row in citations.itertuples(index=False):
182
+ if row.author_id not in groups:
183
+ raise ValueError('Invalid prior author identity: occurrence references an unknown author')
184
+ if works is not None:
185
+ names = _row_authors(works.loc[row.record_id]) if row.record_id in works.index else []
186
+ position = int(row.position)
187
+ if position != row.position or not 0 <= position < len(names) or names[position] != row.raw_author:
188
+ # Older code split comma-bearing author strings differently, so
189
+ # a handful of legacy occurrences no longer describe the works
190
+ # table. Callers that pass `skipped` keep the rest of the
191
+ # registry: dropping an occurrence only removes an anchor, and
192
+ # a cluster with no surviving ancestor is allocated a fresh ID
193
+ # rather than silently continuing the wrong one.
194
+ if skipped is None:
195
+ raise ValueError('Invalid prior author identity: occurrence differs from previous works')
196
+ skipped.append((row.author_id, row.record_id, position, row.raw_author))
197
+ continue
198
+ groups[row.author_id].add(occurrence_key(row.record_id, row.position, row.raw_author))
199
+ return reconcile(groups)[1]
citegraph/ids.py ADDED
@@ -0,0 +1,106 @@
1
+ """Stable identifier generation for works.
2
+
3
+ We don't want IDs to change between runs, so they are derived from the
4
+ content (first author + year + a title prefix) rather than from row index.
5
+ The single ``w-`` prefix is role-free on purpose: a work's role (core
6
+ source vs cited stub) can change over its life; its identity must not.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+
13
+ from slugify import slugify
14
+
15
+ _NON_ALPHA = re.compile(r"[^a-zA-Z]+")
16
+
17
+
18
+ def _first_author_token(authors: str | list[str]) -> str:
19
+ """Return a single lower-case surname token for the first author.
20
+
21
+ Handles both ``"Ostrom, E."`` (last-name-first) and ``"Elinor Ostrom"``
22
+ (first-name-first) styles. For multi-author lists, only the first
23
+ author is considered.
24
+ """
25
+ if isinstance(authors, list):
26
+ first = authors[0] if authors else ""
27
+ elif not isinstance(authors, str):
28
+ # NaN or other non-string scalars from pandas
29
+ return "unknown"
30
+ else:
31
+ first = authors.split(";")[0] if authors else ""
32
+ if "," not in first and " and " in first:
33
+ first = first.split(" and ")[0]
34
+
35
+ first = first.strip()
36
+ if not first:
37
+ return "unknown"
38
+
39
+ if "," in first:
40
+ surname = first.split(",")[0]
41
+ else:
42
+ tokens = [t for t in _NON_ALPHA.split(first) if t]
43
+ surname = tokens[-1] if tokens else ""
44
+
45
+ surname = "".join(ch for ch in surname if ch.isalpha())
46
+ return surname.lower() or "unknown"
47
+
48
+
49
+ def make_work_id(
50
+ authors: str | list[str],
51
+ year: int | str | None,
52
+ title: str,
53
+ ) -> str:
54
+ """Build a stable, slug-style work id from bibliographic fields.
55
+
56
+ Examples
57
+ --------
58
+ >>> make_work_id("Ostrom, E.", 1990, "Governing the Commons")
59
+ 'w-ostrom-1990-governing-the-commons'
60
+ """
61
+ author = _first_author_token(authors)
62
+ year_token = str(year) if year not in (None, "") else "nd"
63
+ title_str = title if isinstance(title, str) else ""
64
+ title_slug = slugify(title_str or "untitled", max_length=40, word_boundary=True) or "untitled"
65
+ return f"w-{author}-{year_token}-{title_slug}"
66
+
67
+
68
+ def assign_source_ids(records: list[dict], previous_registry: dict | None = None) -> tuple[list[dict], dict]:
69
+ """Allocate source observation IDs without dropping collisions or reusing history."""
70
+ from citegraph.io import metadata_fingerprint
71
+
72
+ previous = previous_registry or {}
73
+ entries = [dict(e) for e in previous.get("entries", [])]
74
+ reserved = set(previous.get("reserved_ids", [])) | {e["id"] for e in entries}
75
+ lookup = {(e["source_file"], e["metadata_fingerprint"]): e["id"] for e in entries}
76
+ if len({e["id"] for e in entries}) != len(entries):
77
+ raise ValueError("source_ids.json assigns one ID to multiple observations; restore the registry")
78
+ result = [dict(r) for r in records]
79
+ collision_ids = set(previous.get("collision_ids", []))
80
+ # Base IDs are not hashes; sort complete metadata first so allocation is reproducible.
81
+ order = sorted(range(len(result)), key=lambda i: (
82
+ str(result[i].get("Title", "")), str(result[i].get("Authors_List", "")),
83
+ str(result[i].get("Journal", "")), str(result[i].get("Year", "")),
84
+ str(result[i]["source_file"])))
85
+ for i in order:
86
+ rec = result[i]
87
+ signature = metadata_fingerprint(rec)
88
+ key = (str(rec["source_file"]), signature)
89
+ if key in lookup:
90
+ rec["id"] = lookup[key]
91
+ continue
92
+ base = make_work_id(rec.get("Authors_List") or rec.get("Authors", ""), rec.get("Year"), rec.get("Title", ""))
93
+ candidate = base
94
+ suffix = 2
95
+ while candidate in reserved:
96
+ collision_ids.add(base)
97
+ candidate = f"{base}-{suffix}"
98
+ suffix += 1
99
+ if candidate != base:
100
+ collision_ids.add(candidate)
101
+ reserved.add(candidate)
102
+ rec["id"] = candidate
103
+ lookup[key] = candidate
104
+ entries.append(dict(source_file=key[0], metadata_fingerprint=signature, id=candidate))
105
+ return result, dict(schema_version=1, entries=entries, reserved_ids=sorted(reserved),
106
+ collision_ids=sorted(collision_ids))