thisness 0.1.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.
Files changed (172) hide show
  1. thisness/__init__.py +13 -0
  2. thisness/_canonical.py +41 -0
  3. thisness/adjudicate.py +173 -0
  4. thisness/assistant.py +246 -0
  5. thisness/benchmark/__init__.py +0 -0
  6. thisness/benchmark/__main__.py +87 -0
  7. thisness/benchmark/data/README.md +13 -0
  8. thisness/benchmark/data/uk/README.md +7 -0
  9. thisness/benchmark/data/uk/ch.parquet +0 -0
  10. thisness/benchmark/data/uk/gleif.parquet +0 -0
  11. thisness/benchmark/data/uk/gleif_rr.parquet +0 -0
  12. thisness/benchmark/data/wfo_fixture.csv.gz +0 -0
  13. thisness/benchmark/evaluate.py +290 -0
  14. thisness/benchmark/oracle.py +142 -0
  15. thisness/benchmark/organizations.py +338 -0
  16. thisness/benchmark/people.py +716 -0
  17. thisness/benchmark/taxa.py +453 -0
  18. thisness/benchmark/uk.py +788 -0
  19. thisness/browse.py +148 -0
  20. thisness/candidates.py +171 -0
  21. thisness/canonicalize.py +146 -0
  22. thisness/cli.py +546 -0
  23. thisness/comparisons/__init__.py +0 -0
  24. thisness/comparisons/base.py +59 -0
  25. thisness/comparisons/organization.py +227 -0
  26. thisness/comparisons/person.py +249 -0
  27. thisness/comparisons/taxon.py +232 -0
  28. thisness/consensus.py +424 -0
  29. thisness/core/__init__.py +106 -0
  30. thisness/core/base.py +14 -0
  31. thisness/core/components.py +53 -0
  32. thisness/core/decisions.py +185 -0
  33. thisness/core/evidence.py +58 -0
  34. thisness/core/identity.py +134 -0
  35. thisness/core/invocation.py +49 -0
  36. thisness/core/ledger.py +49 -0
  37. thisness/core/observation.py +86 -0
  38. thisness/core/review.py +34 -0
  39. thisness/core/run.py +266 -0
  40. thisness/core/types.py +51 -0
  41. thisness/dossier.py +554 -0
  42. thisness/edges.py +92 -0
  43. thisness/engine.py +1108 -0
  44. thisness/fellegi_sunter.py +369 -0
  45. thisness/ids.py +176 -0
  46. thisness/labels.py +87 -0
  47. thisness/llm/__init__.py +23 -0
  48. thisness/llm/client.py +219 -0
  49. thisness/llm/prices.py +31 -0
  50. thisness/llm/privacy.py +57 -0
  51. thisness/llm/prompts/.keep +0 -0
  52. thisness/llm/prompts/adjudicate_pairs.v1.md +22 -0
  53. thisness/llm/prompts/adjudicate_pairs.v2.md +20 -0
  54. thisness/llm/prompts/group_names.v1.md +14 -0
  55. thisness/llm/prompts/group_names.v2.md +13 -0
  56. thisness/llm/prompts/recommend_review.v1.md +21 -0
  57. thisness/llm/prompts/recommend_review.v2.md +21 -0
  58. thisness/llm/prompts.py +83 -0
  59. thisness/llm/providers/__init__.py +7 -0
  60. thisness/llm/providers/anthropic.py +60 -0
  61. thisness/llm/providers/function.py +24 -0
  62. thisness/llm/providers/openai_compatible.py +90 -0
  63. thisness/llm/providers/replay.py +55 -0
  64. thisness/llm/types.py +57 -0
  65. thisness/name_only.py +450 -0
  66. thisness/normalize/__init__.py +0 -0
  67. thisness/normalize/organization.py +283 -0
  68. thisness/normalize/person.py +417 -0
  69. thisness/normalize/taxon.py +317 -0
  70. thisness/policy.py +54 -0
  71. thisness/profiles/__init__.py +0 -0
  72. thisness/profiles/base.py +306 -0
  73. thisness/profiles/data/organization_starter.json +184 -0
  74. thisness/profiles/data/person_starter.json +240 -0
  75. thisness/profiles/data/taxon_starter.json +189 -0
  76. thisness/profiles/organization.py +100 -0
  77. thisness/profiles/person.py +129 -0
  78. thisness/profiles/taxon.py +129 -0
  79. thisness/py.typed +0 -0
  80. thisness/relationships.py +113 -0
  81. thisness/reviews.py +294 -0
  82. thisness/scoring.py +196 -0
  83. thisness/starter.py +108 -0
  84. thisness/storage/__init__.py +21 -0
  85. thisness/storage/_commit.py +632 -0
  86. thisness/storage/duckdb/__init__.py +3 -0
  87. thisness/storage/duckdb/invariants.py +243 -0
  88. thisness/storage/duckdb/migrations/0001_initial.sql +243 -0
  89. thisness/storage/duckdb/migrations/0002_pair_constraints.sql +13 -0
  90. thisness/storage/duckdb/migrations/0003_model_invocations.sql +28 -0
  91. thisness/storage/duckdb/store.py +1615 -0
  92. thisness/storage/errors.py +49 -0
  93. thisness/storage/protocol.py +136 -0
  94. thisness/training.py +151 -0
  95. thisness/ui/__init__.py +5 -0
  96. thisness/ui/app.py +538 -0
  97. thisness/ui/static/THIRD_PARTY.md +203 -0
  98. thisness/ui/static/app.css +408 -0
  99. thisness/ui/static/app.js +135 -0
  100. thisness/ui/static/icons/adjustments-horizontal.svg +23 -0
  101. thisness/ui/static/icons/alert-triangle.svg +17 -0
  102. thisness/ui/static/icons/arrow-back-up.svg +16 -0
  103. thisness/ui/static/icons/arrows-join.svg +17 -0
  104. thisness/ui/static/icons/arrows-split.svg +18 -0
  105. thisness/ui/static/icons/briefcase.svg +18 -0
  106. thisness/ui/static/icons/building.svg +22 -0
  107. thisness/ui/static/icons/cake.svg +17 -0
  108. thisness/ui/static/icons/chart-dots.svg +20 -0
  109. thisness/ui/static/icons/chevron-down.svg +15 -0
  110. thisness/ui/static/icons/chevron-right.svg +15 -0
  111. thisness/ui/static/icons/circle-check.svg +16 -0
  112. thisness/ui/static/icons/circle-dashed.svg +22 -0
  113. thisness/ui/static/icons/circle-x.svg +16 -0
  114. thisness/ui/static/icons/clock.svg +16 -0
  115. thisness/ui/static/icons/database.svg +17 -0
  116. thisness/ui/static/icons/external-link.svg +17 -0
  117. thisness/ui/static/icons/file-text.svg +19 -0
  118. thisness/ui/static/icons/filter.svg +15 -0
  119. thisness/ui/static/icons/fingerprint.svg +19 -0
  120. thisness/ui/static/icons/git-branch.svg +20 -0
  121. thisness/ui/static/icons/git-merge.svg +19 -0
  122. thisness/ui/static/icons/hash.svg +18 -0
  123. thisness/ui/static/icons/history.svg +16 -0
  124. thisness/ui/static/icons/home.svg +17 -0
  125. thisness/ui/static/icons/id.svg +19 -0
  126. thisness/ui/static/icons/layout-sidebar-right.svg +16 -0
  127. thisness/ui/static/icons/leaf.svg +16 -0
  128. thisness/ui/static/icons/link.svg +17 -0
  129. thisness/ui/static/icons/list-check.svg +20 -0
  130. thisness/ui/static/icons/mail.svg +16 -0
  131. thisness/ui/static/icons/map-pin.svg +16 -0
  132. thisness/ui/static/icons/message-question.svg +19 -0
  133. thisness/ui/static/icons/phone.svg +15 -0
  134. thisness/ui/static/icons/player-play.svg +15 -0
  135. thisness/ui/static/icons/plus.svg +16 -0
  136. thisness/ui/static/icons/scale.svg +19 -0
  137. thisness/ui/static/icons/search.svg +16 -0
  138. thisness/ui/static/icons/sparkles.svg +15 -0
  139. thisness/ui/static/icons/timeline-event.svg +18 -0
  140. thisness/ui/static/icons/user.svg +16 -0
  141. thisness/ui/static/icons/users.svg +18 -0
  142. thisness/ui/static/icons/world.svg +19 -0
  143. thisness/ui/static/icons/x.svg +16 -0
  144. thisness/ui/static/theme.css +525 -0
  145. thisness/ui/static/vendor/Sortable.min.js +2 -0
  146. thisness/ui/static/vendor/htmx.min.js +1 -0
  147. thisness/ui/static/vendor/inter-latin-ext-wght-normal.woff2 +0 -0
  148. thisness/ui/static/vendor/inter-latin-wght-normal.woff2 +0 -0
  149. thisness/ui/templates/_macros.html +27 -0
  150. thisness/ui/templates/_panel_head.html +4 -0
  151. thisness/ui/templates/base.html +53 -0
  152. thisness/ui/templates/entities.html +48 -0
  153. thisness/ui/templates/entity.html +71 -0
  154. thisness/ui/templates/entity_tab.html +106 -0
  155. thisness/ui/templates/llm.html +32 -0
  156. thisness/ui/templates/model.html +35 -0
  157. thisness/ui/templates/not_found.html +4 -0
  158. thisness/ui/templates/panel_candidate.html +23 -0
  159. thisness/ui/templates/panel_decision.html +41 -0
  160. thisness/ui/templates/panel_entity.html +37 -0
  161. thisness/ui/templates/panel_invocation.html +16 -0
  162. thisness/ui/templates/panel_observation.html +22 -0
  163. thisness/ui/templates/relationships.html +32 -0
  164. thisness/ui/templates/review.html +106 -0
  165. thisness/ui/templates/reviews.html +32 -0
  166. thisness/ui/templates/runs.html +20 -0
  167. thisness/ui/templates/search_results.html +7 -0
  168. thisness-0.1.0.dist-info/METADATA +183 -0
  169. thisness-0.1.0.dist-info/RECORD +172 -0
  170. thisness-0.1.0.dist-info/WHEEL +4 -0
  171. thisness-0.1.0.dist-info/entry_points.txt +2 -0
  172. thisness-0.1.0.dist-info/licenses/LICENSE +201 -0
thisness/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ """Thisness: identity resolution for imperfect observations."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ from thisness.engine import ObservationOutcome, RunResult, Thisness
6
+ from thisness.ids import IdFactory
7
+
8
+ try:
9
+ __version__ = version("thisness")
10
+ except PackageNotFoundError: # running from a source tree without an install
11
+ __version__ = "0.0.0"
12
+
13
+ __all__ = ["IdFactory", "ObservationOutcome", "RunResult", "Thisness", "__version__"]
thisness/_canonical.py ADDED
@@ -0,0 +1,41 @@
1
+ """Canonical JSON and content digests.
2
+
3
+ Fingerprints and component config hashes must not depend on dict key order or
4
+ on how a value was spelled in Python. Sets are rejected because their order is
5
+ not defined.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ from datetime import date, datetime
13
+ from typing import Any
14
+
15
+
16
+ def _default(value: Any) -> Any:
17
+ if isinstance(value, datetime):
18
+ if value.tzinfo is None:
19
+ raise TypeError("naive datetimes are ambiguous; use timezone-aware datetimes")
20
+ return value.isoformat()
21
+ if isinstance(value, date):
22
+ return value.isoformat()
23
+ if isinstance(value, (set, frozenset)):
24
+ raise TypeError("sets have no defined order; use a sorted list")
25
+ raise TypeError(f"{type(value).__name__} is not JSON-serializable")
26
+
27
+
28
+ def canonical_json(value: Any) -> str:
29
+ return json.dumps(
30
+ value,
31
+ sort_keys=True,
32
+ separators=(",", ":"),
33
+ ensure_ascii=False,
34
+ allow_nan=False,
35
+ default=_default,
36
+ )
37
+
38
+
39
+ def digest(value: Any) -> str:
40
+ """SHA-256 hex digest of the canonical JSON of ``value``."""
41
+ return hashlib.sha256(canonical_json(value).encode("utf-8")).hexdigest()
thisness/adjudicate.py ADDED
@@ -0,0 +1,173 @@
1
+ """The LLM adjudicator (design 05 §4, PRD appendix A.7).
2
+
3
+ A model judges the node pairs a run's Fellegi-Sunter edge policy left contested, as
4
+ one more pair resolver in its own family. It is given each pair's two records,
5
+ filtered by the profile's privacy policy, and the comparison results behind the
6
+ pair, each with an id it must cite. Answers that cite ids they were not given, or
7
+ name a relation the profile does not offer, are discarded.
8
+
9
+ In ``shadow`` mode answers are recorded and change nothing. In ``decide`` mode a
10
+ ``same`` answer makes the edge strong when the Fellegi-Sunter weight is at least
11
+ ``floor_bits``, and a ``different`` answer makes it negative; consensus, holdout
12
+ and the blast-radius policy then apply exactly as they do to any edge.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from collections.abc import Mapping, Sequence
18
+ from concurrent.futures import ThreadPoolExecutor
19
+ from dataclasses import dataclass, field, replace
20
+ from typing import Any, Literal
21
+
22
+ from pydantic import BaseModel, Field
23
+
24
+ from thisness.core.components import ComponentRef
25
+ from thisness.llm.client import Answer, BudgetExhausted, LLMClient
26
+ from thisness.llm.privacy import filter_attributes
27
+ from thisness.llm.prompts import Prompt
28
+
29
+ Decision = Literal["same", "different", "related", "unsure"]
30
+ ReasonCode = Literal[
31
+ "NAME_EQUIVALENT", "NAME_TYPO", "ABBREVIATION_OR_ACRONYM", "LEGAL_FORM_ONLY",
32
+ "NICKNAME_OR_TRANSLITERATION", "SHARED_IDENTIFIER", "CONFLICTING_IDENTIFIER",
33
+ "SHARED_CONTACT", "SAME_LOCATION", "DIFFERENT_LOCATION", "DIFFERENT_DESCRIPTOR",
34
+ "COMMON_NAME", "SHARED_HOUSEHOLD", "DIFFERENT_BIRTH_DATE", "GROUP_OR_PARENT",
35
+ "INSUFFICIENT_EVIDENCE", "OTHER",
36
+ ] # fmt: skip
37
+
38
+
39
+ class PairAnswer(BaseModel):
40
+ pair: str
41
+ decision: Decision
42
+ relation: str | None = None
43
+ supporting: list[str] = Field(default_factory=list)
44
+ contradicting: list[str] = Field(default_factory=list)
45
+ reason_codes: list[ReasonCode] = Field(default_factory=list)
46
+ rationale: str | None = Field(default=None, max_length=600)
47
+
48
+
49
+ class AdjudicationAnswer(BaseModel):
50
+ answers: list[PairAnswer]
51
+
52
+
53
+ def default_prompt() -> Prompt:
54
+ return Prompt.load("adjudicate_pairs", "2", AdjudicationAnswer)
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class PairCase:
59
+ """One contested node pair, as the adjudicator sees it."""
60
+
61
+ nodes: tuple[str, str]
62
+ records: tuple[dict[str, Any], dict[str, Any]] # unfiltered; filtered on the way out
63
+ comparisons: tuple[dict[str, Any], ...] # {"id", "comparison", "level", "bits"}
64
+ evidence_keys: Mapping[str, tuple[str, str, str]] # local id -> (left, right, comparison)
65
+ weight: float # the strongest Fellegi-Sunter weight behind the pair
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class Adjudication:
70
+ case: PairCase
71
+ answer: PairAnswer | None
72
+ invocation_id: str | None
73
+ status: str # ok, invalid_citation, unknown_relation, missing, failed, skipped
74
+ applied: bool = False
75
+
76
+
77
+ @dataclass
78
+ class Adjudicator:
79
+ client: LLMClient
80
+ model: str = "claude-sonnet-5"
81
+ mode: Literal["shadow", "decide"] = "shadow"
82
+ floor_bits: float = 6.0
83
+ pairs_per_call: int = 8
84
+ max_pairs: int | None = 500
85
+ prompt: Prompt = field(default_factory=default_prompt)
86
+ # A model's "same" yields to a contradiction (design 06 §10.7): where the edges it
87
+ # set strong would put a negative or cannot-link pair inside one component, they go
88
+ # back to contested and the component is settled on the evidence alone. A wrong
89
+ # "same" then costs a review of that pair, not a hold on a whole taxon.
90
+ same_yields: bool = True
91
+
92
+ @property
93
+ def ref(self) -> ComponentRef:
94
+ config = {"model": self.model, "mode": self.mode, "floor_bits": self.floor_bits,
95
+ "pairs_per_call": self.pairs_per_call, "prompt": self.prompt.ref.ref} # fmt: skip
96
+ if not self.same_yields: # the original policy keeps its original ref
97
+ return ComponentRef.of("resolver", "llm_adjudicator", "1", config)
98
+ return ComponentRef.of("resolver", "llm_adjudicator", "2", {**config, "same_yields": True})
99
+
100
+ def edge(self, adjudication: Adjudication) -> str | None:
101
+ """The edge class a decided answer sets, or None to leave the edge as it is."""
102
+ answer = adjudication.answer
103
+ if self.mode != "decide" or answer is None:
104
+ return None
105
+ if answer.decision == "same" and adjudication.case.weight >= self.floor_bits:
106
+ return "strong"
107
+ if answer.decision == "different":
108
+ return "negative"
109
+ return None
110
+
111
+ def for_profile(self, profile: Any) -> Adjudicator:
112
+ """This adjudicator with the profile's guidance in its prompt (design 06 §6)."""
113
+ prompt = self.prompt.for_profile(profile)
114
+ return self if prompt is self.prompt else replace(self, prompt=prompt)
115
+
116
+ def adjudicate(
117
+ self,
118
+ cases: Sequence[PairCase],
119
+ *,
120
+ entity_type: str,
121
+ attributes: Mapping[str, Any],
122
+ relations: Sequence[str],
123
+ run_id: str | None = None,
124
+ stage_id: str | None = None,
125
+ ) -> list[Adjudication]:
126
+ ordered = sorted(cases, key=lambda c: (-c.weight, c.nodes))
127
+ chosen = ordered if self.max_pairs is None else ordered[: self.max_pairs]
128
+ skipped = [Adjudication(c, None, None, "skipped") for c in ordered[len(chosen) :]]
129
+ batches = [chosen[i : i + self.pairs_per_call]
130
+ for i in range(0, len(chosen), self.pairs_per_call)] # fmt: skip
131
+ local = self.client.provider.local
132
+
133
+ def payload(batch: Sequence[PairCase]) -> dict[str, Any]:
134
+ pairs = []
135
+ for i, case in enumerate(batch):
136
+ a, b = (dict(zip(("fields", "withheld"),
137
+ filter_attributes(r, attributes, local=local), strict=True))
138
+ for r in case.records) # fmt: skip
139
+ pairs.append({"pair": f"P{i + 1}", "a": {"ref": case.nodes[0], **a},
140
+ "b": {"ref": case.nodes[1], **b},
141
+ "comparisons": list(case.comparisons)}) # fmt: skip
142
+ return {"entity_type": entity_type, "relations": list(relations), "pairs": pairs}
143
+
144
+ def run(batch: Sequence[PairCase]) -> list[Adjudication]:
145
+ try:
146
+ result: Answer = self.client.call(self.prompt, model=self.model,
147
+ payload=payload(batch), run_id=run_id,
148
+ stage_id=stage_id) # fmt: skip
149
+ except BudgetExhausted:
150
+ return [Adjudication(c, None, None, "skipped") for c in batch]
151
+ invocation = result.invocation.invocation_id
152
+ if not result.ok:
153
+ return [Adjudication(c, None, invocation, "failed") for c in batch]
154
+ by_slot = {}
155
+ for answer in result.parsed.answers:
156
+ by_slot.setdefault(answer.pair, answer)
157
+ out = []
158
+ for i, case in enumerate(batch):
159
+ answer = by_slot.get(f"P{i + 1}")
160
+ if answer is None:
161
+ out.append(Adjudication(case, None, invocation, "missing"))
162
+ elif not set(answer.supporting + answer.contradicting) <= set(case.evidence_keys):
163
+ out.append(Adjudication(case, None, invocation, "invalid_citation"))
164
+ elif answer.decision == "related" and answer.relation not in relations:
165
+ out.append(Adjudication(case, None, invocation, "unknown_relation"))
166
+ else:
167
+ out.append(Adjudication(case, answer, invocation, "ok"))
168
+ return out
169
+
170
+ workers = max(1, self.client.policy.concurrency)
171
+ with ThreadPoolExecutor(max_workers=workers) as pool:
172
+ results = [a for batch in pool.map(run, batches) for a in batch]
173
+ return results + skipped
thisness/assistant.py ADDED
@@ -0,0 +1,246 @@
1
+ """The review assistant (design 05 §5, PRD appendix A.15).
2
+
3
+ A model answers an open review's own question from its case, and the answer is
4
+ recorded as a ``ReviewRecommendation``: shown to the reviewer as a suggestion that
5
+ can be applied to the board, never submitted, never a label. When the reviewer
6
+ answers, their ``ReviewDecision`` records which recommendation they saw, so the
7
+ model's agreement with people is measured on real decisions.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Sequence
13
+ from concurrent.futures import ThreadPoolExecutor
14
+ from dataclasses import dataclass, field
15
+ from itertools import combinations
16
+ from typing import TYPE_CHECKING, Any, Literal
17
+
18
+ from pydantic import BaseModel, Field
19
+
20
+ from thisness.adjudicate import ReasonCode
21
+ from thisness.core.components import ComponentRef
22
+ from thisness.core.decisions import Actor, ReviewDecision, ReviewRecommendation
23
+ from thisness.llm.client import BudgetExhausted, LLMClient
24
+ from thisness.llm.privacy import filter_attributes
25
+ from thisness.llm.prompts import Prompt
26
+ from thisness.profiles.base import AttributeDefinition
27
+
28
+ if TYPE_CHECKING:
29
+ from thisness.engine import Thisness
30
+
31
+ MAX_COMPARISONS = 40
32
+
33
+
34
+ class RecommendationAnswer(BaseModel):
35
+ resolution: Literal["same", "different", "groups", "related", "unsure"]
36
+ groups: list[list[str]] | None = None
37
+ relation: str | None = None
38
+ cited: list[str] = Field(default_factory=list)
39
+ reason_codes: list[ReasonCode] = Field(default_factory=list)
40
+ rationale: str | None = Field(default=None, max_length=800)
41
+
42
+
43
+ def default_prompt() -> Prompt:
44
+ return Prompt.load("recommend_review", "2", RecommendationAnswer)
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class Recommended:
49
+ review_id: str
50
+ recommendation: ReviewRecommendation | None
51
+ invocation_id: str | None
52
+ status: str # ok, invalid_groups, invalid_citation, unknown_relation, failed, skipped
53
+
54
+
55
+ def review_labels(engine: Thisness, case: Any) -> tuple[dict[str, str], list[tuple]]:
56
+ """The labels a review's records and comparisons carry in the assistant's request:
57
+ records R1, R2, ... in the case's order; comparisons C1, C2, ... strongest first,
58
+ as ``(label, judgment, record label, record label, weight)``. The Workbench shows the
59
+ same labels, so a suggestion's rationale reads against the board."""
60
+ refs = {node.node_id: f"R{i + 1}" for i, node in enumerate(case.nodes)}
61
+ member_of = {member: node.node_id for node in case.nodes if node.kind == "entity"
62
+ for member in engine.members(node.node_id)} # fmt: skip
63
+
64
+ def ref_of(obs: str) -> str | None:
65
+ return refs.get(obs) or refs.get(member_of.get(obs, ""))
66
+
67
+ scored = []
68
+ for j in case.judgments:
69
+ a, b = ref_of(j.left_id), ref_of(j.right_id)
70
+ if a is None or b is None or a == b:
71
+ continue
72
+ weight = next((float(s.value) for s in j.scores
73
+ if s.kind == "fellegi_sunter_weight"), None) # fmt: skip
74
+ scored.append((abs(weight) if weight is not None else 0.0, j, a, b, weight))
75
+ ranked = sorted(scored, key=lambda x: (-x[0], x[1].decision_id))[:MAX_COMPARISONS]
76
+ return refs, [(f"C{i + 1}", j, a, b, w) for i, (_, j, a, b, w) in enumerate(ranked)]
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class _Request:
81
+ review_id: str
82
+ prompt: Prompt
83
+ payload: dict[str, Any]
84
+ refs: dict[str, str]
85
+ node_of_ref: dict[str, str]
86
+ current: dict[str, list[str]]
87
+ judgment_of: dict[str, str]
88
+ relations: list[str]
89
+
90
+
91
+ @dataclass
92
+ class ReviewAssistant:
93
+ client: LLMClient
94
+ model: str = "claude-sonnet-5"
95
+ prompt: Prompt = field(default_factory=default_prompt)
96
+
97
+ @property
98
+ def ref(self) -> ComponentRef:
99
+ return ComponentRef.of("resolver", "review_assistant", "1",
100
+ {"model": self.model, "prompt": self.prompt.ref.ref}) # fmt: skip
101
+
102
+ def _prepare(self, engine: Thisness, review_id: str) -> _Request:
103
+ """Everything the request needs, read from the store."""
104
+ case = engine.review_case(review_id)
105
+ refs, labelled = review_labels(engine, case)
106
+ node_of_ref = {r: n for n, r in refs.items()}
107
+ entity_type = None
108
+ current: dict[str, list[str]] = {}
109
+ records = []
110
+ local = self.client.provider.local
111
+ for node in case.nodes:
112
+ current.setdefault(node.entity_id or node.node_id, []).append(refs[node.node_id])
113
+ profile = engine.profiles.get(engine.get_entity(node.entity_id)[0].entity_type) \
114
+ if node.entity_id else None # fmt: skip
115
+ entity_type = entity_type or (profile.entity_type if profile else None)
116
+ definitions = dict(profile.attributes) if profile else {}
117
+ attributes = dict(node.attributes)
118
+ if node.kind == "entity":
119
+ attributes["observation_count"] = node.size
120
+ definitions["observation_count"] = AttributeDefinition(
121
+ name="observation_count", data_type="integer") # fmt: skip
122
+ fields, withheld = filter_attributes(attributes, definitions, local=local)
123
+ source = node.source.get("type") if node.source else None
124
+ records.append({"ref": refs[node.node_id], "record_id": node.node_id,
125
+ "kind": node.kind, "source": source, "fields": fields,
126
+ "withheld": withheld}) # fmt: skip
127
+
128
+ comparisons, judgment_of = [], {}
129
+ evidence = {e.evidence_id: e for e in engine.store.evidence(
130
+ sorted({e for _, j, *_ in labelled for e in j.evidence_ids}))} # fmt: skip
131
+ for local_id, j, a, b, weight in labelled:
132
+ levels = {e.attribute: e.level for i in j.evidence_ids if (e := evidence.get(i))
133
+ and e.attribute and e.level} # fmt: skip
134
+ comparisons.append({"id": local_id, "a": a, "b": b, "by": j.actor.kind,
135
+ "weight": None if weight is None else round(weight, 1),
136
+ "verdict": str(j.verdict), "levels": levels}) # fmt: skip
137
+ judgment_of[local_id] = j.decision_id
138
+
139
+ profile = engine.profiles.get(entity_type) if entity_type else None
140
+ relations = list(profile.relations) if profile else ["related_to"]
141
+ payload: dict[str, Any] = {
142
+ "entity_type": entity_type, "question": case.question, "relations": relations,
143
+ "records": records, "current_groups": sorted(current.values()),
144
+ "comparisons": comparisons,
145
+ } # fmt: skip
146
+ prompt = self.prompt.for_profile(engine.profiles.get(entity_type) if entity_type else None)
147
+ return _Request(review_id, prompt, payload, refs, node_of_ref, current, judgment_of,
148
+ relations) # fmt: skip
149
+
150
+ def _ask(self, request: _Request) -> Any:
151
+ """The model call: the only step that runs in parallel."""
152
+ try:
153
+ return self.client.call(request.prompt, model=self.model, payload=request.payload)
154
+ except BudgetExhausted:
155
+ return None
156
+
157
+ def _finish(self, engine: Thisness, request: _Request, answer: Any) -> Recommended:
158
+ """Check the answer and record it, one review at a time."""
159
+ review_id, prompt, refs = request.review_id, request.prompt, request.refs
160
+ node_of_ref, current = request.node_of_ref, request.current
161
+ judgment_of, relations = request.judgment_of, request.relations
162
+ if answer is None:
163
+ return Recommended(review_id, None, None, "skipped")
164
+ invocation = answer.invocation.invocation_id
165
+ if not answer.ok:
166
+ return Recommended(review_id, None, invocation, "failed")
167
+ parsed: RecommendationAnswer = answer.parsed
168
+ if not set(parsed.cited) <= set(judgment_of):
169
+ return Recommended(review_id, None, invocation, "invalid_citation")
170
+ groups = _partition(parsed, sorted(current.values()), sorted(refs.values()))
171
+ if parsed.resolution != "unsure" and groups is None:
172
+ return Recommended(review_id, None, invocation, "invalid_groups")
173
+ if parsed.resolution == "related" and parsed.relation not in relations:
174
+ return Recommended(review_id, None, invocation, "unknown_relation")
175
+ engine.store.register_component(self.ref, {"model": self.model})
176
+ recommendation = ReviewRecommendation(
177
+ decision_id=engine.ids.decision(), actor=Actor(kind="agent", id=self.model),
178
+ review_id=review_id, resolution=parsed.resolution,
179
+ groups=None if groups is None else tuple(
180
+ tuple(sorted(node_of_ref[r] for r in g)) for g in groups),
181
+ relation=parsed.relation if parsed.resolution == "related" else None,
182
+ reason_codes=tuple(parsed.reason_codes), rationale=parsed.rationale,
183
+ cited_judgments=tuple(judgment_of[c] for c in parsed.cited),
184
+ component_refs=(self.ref.ref, prompt.ref.ref),
185
+ invocation_id=invocation, created_at=engine.clock(),
186
+ ) # fmt: skip
187
+ engine.store.record_decisions([recommendation])
188
+ return Recommended(review_id, recommendation, invocation, "ok")
189
+
190
+ def recommend(self, engine: Thisness, review_id: str) -> Recommended:
191
+ request = self._prepare(engine, review_id)
192
+ return self._finish(engine, request, self._ask(request))
193
+
194
+ def recommend_many(self, engine: Thisness, review_ids: Sequence[str]) -> list[Recommended]:
195
+ """Recommendations for many reviews, the model asked ``policy.concurrency`` at a
196
+ time. Reading the store and recording answers stay sequential and in order."""
197
+ requests = [self._prepare(engine, r) for r in review_ids]
198
+ workers = max(1, min(self.client.policy.concurrency, len(requests)))
199
+ with ThreadPoolExecutor(max_workers=workers) as pool:
200
+ answers = list(pool.map(self._ask, requests))
201
+ return [self._finish(engine, r, a) for r, a in zip(requests, answers, strict=True)]
202
+
203
+
204
+ def _partition(answer: RecommendationAnswer, current: list[list[str]],
205
+ refs: list[str]) -> list[list[str]] | None: # fmt: skip
206
+ """The grouping an answer means, or None when it is not a valid one."""
207
+ if answer.resolution == "unsure":
208
+ return None
209
+ if answer.resolution == "same":
210
+ return [refs]
211
+ if answer.resolution == "different":
212
+ return current
213
+ if answer.resolution == "related":
214
+ return current if len(current) == 2 else None
215
+ groups = [list(g) for g in answer.groups or [] if g]
216
+ flat = [r for g in groups for r in g]
217
+ return groups if sorted(flat) == refs and len(flat) == len(set(flat)) else None
218
+
219
+
220
+ def agreement(decisions: Sequence[ReviewDecision],
221
+ recommendations: dict[str, ReviewRecommendation]) -> dict[str, Any]: # fmt: skip
222
+ """How often reviewers' answers matched the recommendation they were shown: the
223
+ whole grouping, and pair by pair (same group or not)."""
224
+ exact = pairs = agreed = n = 0
225
+ by_resolution: dict[str, list[int]] = {}
226
+ for decision in decisions:
227
+ rec = recommendations.get(decision.recommendation_id or "")
228
+ if rec is None or rec.groups is None or decision.groups is None:
229
+ continue
230
+ n += 1
231
+ mine = {node: i for i, g in enumerate(decision.groups) for node in g}
232
+ theirs = {node: i for i, g in enumerate(rec.groups) for node in g}
233
+ same_grouping = {frozenset(g) for g in decision.groups} == {frozenset(g)
234
+ for g in rec.groups} # fmt: skip
235
+ exact += same_grouping
236
+ by_resolution.setdefault(rec.resolution, []).append(int(same_grouping))
237
+ for a, b in combinations(sorted(mine), 2):
238
+ if a in theirs and b in theirs:
239
+ pairs += 1
240
+ agreed += (mine[a] == mine[b]) == (theirs[a] == theirs[b])
241
+ return {
242
+ "reviews": n,
243
+ "exact": exact / n if n else None,
244
+ "pairwise": agreed / pairs if pairs else None,
245
+ "exact_by_recommendation": {k: sum(v) / len(v) for k, v in sorted(by_resolution.items())},
246
+ }
File without changes
@@ -0,0 +1,87 @@
1
+ """``python -m thisness.benchmark run``: resolve synthetic benchmarks and report.
2
+ ``python -m thisness.benchmark starter``: rebuild a shipped starter model."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ from pathlib import Path
9
+
10
+ from thisness.benchmark import organizations, people, taxa
11
+ from thisness.benchmark.evaluate import PROFILES, run_benchmark
12
+ from thisness.starter import write_starter
13
+
14
+ GENERATORS = {"organization": organizations.generate, "person": people.generate,
15
+ "taxon": taxa.standard} # fmt: skip
16
+
17
+
18
+ def main() -> None:
19
+ parser = argparse.ArgumentParser(prog="python -m thisness.benchmark")
20
+ sub = parser.add_subparsers(dest="command", required=True)
21
+ run = sub.add_parser("run", help="resolve benchmarks and print reports")
22
+ run.add_argument("--type", default="organization", choices=sorted(GENERATORS))
23
+ run.add_argument("--size", "--orgs", type=int, nargs="+", default=[805], dest="orgs",
24
+ help="entities per benchmark") # fmt: skip
25
+ run.add_argument("--seeds", type=int, nargs="+", default=[11])
26
+ run.add_argument("--json", action="store_true", help="print full reports as JSON")
27
+ run.add_argument("--reference", action="store_true",
28
+ help="taxon only: WFO's own rows as observations too (design 06 §5)") # fmt: skip
29
+ run.add_argument(
30
+ "--col",
31
+ action="store_true",
32
+ help="taxon only: the Catalogue of Life's rows too, a second authority",
33
+ )
34
+ run.add_argument(
35
+ "--authority",
36
+ default=None,
37
+ help="taxon only: the reference authority's source (default wfo)",
38
+ )
39
+ sub.add_parser("wfo-fixture", help="rebuild the shipped WFO fixture (fetches three families)")
40
+ sub.add_parser("uk-stage", help="fetch GLEIF and Companies House and stage them (design 07)")
41
+ sub.add_parser("uk-truth", help="count how good the GLEIF to Companies House join is")
42
+ uk_audit = sub.add_parser("uk-audit", help="read a sample of the join")
43
+ uk_audit.add_argument("--n", type=int, default=400)
44
+ sub.add_parser("uk-fixture", help="rebuild the shipped UK fixture from the staged data")
45
+ starter = sub.add_parser("starter", help="rebuild a starter model")
46
+ starter.add_argument("--type", default="organization", choices=sorted(GENERATORS))
47
+ starter.add_argument("--out", type=Path, default=None)
48
+ args = parser.parse_args()
49
+
50
+ if args.command == "run":
51
+ for n in args.orgs:
52
+ for seed in args.seeds:
53
+ options = {k: True for k in ("reference", "col") if getattr(args, k)}
54
+ profile = (PROFILES[args.type](authority=args.authority)
55
+ if args.authority else None) # fmt: skip
56
+ report, _, _ = run_benchmark(GENERATORS[args.type](n, seed=seed, **options),
57
+ profile=profile) # fmt: skip
58
+ print(json.dumps(report.as_dict(), indent=1) if args.json else report.summary())
59
+ elif args.command.startswith("uk-"):
60
+ from thisness.benchmark import uk
61
+
62
+ if args.command == "uk-stage":
63
+ print(f"staged {uk.stage()}")
64
+ elif args.command == "uk-truth":
65
+ print(json.dumps(uk.truth_report(), indent=1))
66
+ elif args.command == "uk-audit":
67
+ report = uk.audit(n=args.n)
68
+ print(json.dumps({k: v for k, v in report.items() if k != "differing_names"}))
69
+ for d in report["differing_names"][:25]:
70
+ print(f"- {d['gleif']!r} | Companies House: {d['companies_house']!r}")
71
+ else:
72
+ print(f"wrote {uk.make_fixture()}")
73
+ elif args.command == "wfo-fixture":
74
+ target = Path(taxa.__file__).parent / "data" / "wfo_fixture.csv.gz"
75
+ taxa.write_rows(taxa.families(taxa.FIXTURE_FAMILIES), target)
76
+ print(f"wrote {target}")
77
+ else:
78
+ import thisness.profiles
79
+
80
+ profile = PROFILES[args.type]()
81
+ default = Path(thisness.profiles.__file__).parent / "data" / profile.starter_model
82
+ path = write_starter(profile, args.out or default)
83
+ print(f"wrote {path}")
84
+
85
+
86
+ if __name__ == "__main__":
87
+ main()
@@ -0,0 +1,13 @@
1
+ # Benchmark data
2
+
3
+ `wfo_fixture.csv.gz`: every species-level name (accepted, and synonyms with the accepted
4
+ taxon each belongs to) in three plant families, Pinaceae, Betulaceae and Juglandaceae,
5
+ from the **World Flora Online Plant List 2026-06**, as published on ChecklistBank
6
+ (dataset 2004). The data is dedicated to the public domain under **CC0 1.0**; we credit
7
+ it as a courtesy:
8
+
9
+ > The World Flora Online Consortium et al. World Flora Online Plant List 2026-06.
10
+ > https://www.worldfloraonline.org, via ChecklistBank (https://www.checklistbank.org/dataset/2004).
11
+
12
+ Rebuild with `python -m thisness.benchmark wfo-fixture`. Other families are fetched on
13
+ demand and cached under `~/.cache/thisness/wfo`.
@@ -0,0 +1,7 @@
1
+ # UK organizations fixture
2
+
3
+ A small slice of real data for tests (design 07): GLEIF Golden Copy Level 1 and 2 records registered with Companies House, and the Companies House records of those companies and their live near-namesakes.
4
+
5
+ Contains GLEIF Golden Copy data (CC0 1.0) and Companies House data, public sector information licensed under the Open Government Licence v3.0.
6
+
7
+ Built from: BasicCompanyDataAsOneFile-2026-10-01.zip (companies_house), 20261009-0800-gleif-goldencopy-lei2-golden-copy.csv.zip (lei2), 20261009-0800-gleif-goldencopy-rr-golden-copy.csv.zip (rr).
Binary file
Binary file