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
@@ -0,0 +1,290 @@
1
+ """Evaluation against ground truth (PRD §45, design 03 §11).
2
+
3
+ Cluster-level identity safety comes first: false-merge clusters, contaminated
4
+ observations, the largest erroneous cluster. Pair metrics are diagnostics only:
5
+ one bad edge contaminates a whole cluster while barely moving pair precision.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ from collections import Counter, defaultdict
12
+ from collections.abc import Callable, Iterable, Mapping
13
+ from dataclasses import asdict, dataclass, field
14
+ from datetime import UTC, datetime
15
+ from itertools import combinations
16
+ from typing import Any
17
+
18
+ from thisness.benchmark.organizations import Benchmark as OrganizationBenchmark
19
+ from thisness.benchmark.people import Benchmark as PeopleBenchmark
20
+ from thisness.benchmark.taxa import Benchmark as TaxonBenchmark
21
+ from thisness.consensus import components
22
+ from thisness.engine import RunResult, Thisness
23
+ from thisness.ids import IdFactory
24
+ from thisness.profiles.base import ResolutionProfile
25
+ from thisness.profiles.organization import organization
26
+ from thisness.profiles.person import person
27
+ from thisness.profiles.taxon import taxon
28
+
29
+ Benchmark = OrganizationBenchmark | PeopleBenchmark | TaxonBenchmark
30
+ PROFILES = {"organization": organization, "person": person, "taxon": taxon}
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class ClusterMetrics:
35
+ observations: int
36
+ clusters: int
37
+ b3_precision: float
38
+ b3_recall: float
39
+ b3_f1: float
40
+ false_merge_clusters: int
41
+ contaminated: int # observations not of their cluster's majority organization
42
+ largest_bad_cluster: int
43
+ by_kind_recall: dict[str, float] = field(default_factory=dict)
44
+ mixed_by_kind: dict[str, int] = field(default_factory=dict) # clusters mixing 2+ orgs of a kind
45
+
46
+
47
+ def cluster_metrics(
48
+ assignment: Mapping[str, str], truth: Mapping[str, str], kind: Mapping[str, str]
49
+ ) -> ClusterMetrics:
50
+ """``assignment``: observation -> cluster id. ``truth``: observation -> org id."""
51
+ clusters: dict[str, set[str]] = defaultdict(set)
52
+ for obs, cluster in assignment.items():
53
+ clusters[cluster].add(obs)
54
+ orgs: dict[str, set[str]] = defaultdict(set)
55
+ for obs, org in truth.items():
56
+ if obs in assignment:
57
+ orgs[org].add(obs)
58
+ p_sum = r_sum = 0.0
59
+ kind_r: dict[str, list[float]] = defaultdict(list)
60
+ for obs, cluster in assignment.items():
61
+ members, true = clusters[cluster], orgs[truth[obs]]
62
+ inter = len(members & true)
63
+ p_sum += inter / len(members)
64
+ recall = inter / len(true)
65
+ r_sum += recall
66
+ kind_r[kind[truth[obs]]].append(recall)
67
+ n = len(assignment) or 1
68
+ precision, recall = p_sum / n, r_sum / n
69
+ bad = [m for m in clusters.values() if len({truth[x] for x in m}) > 1]
70
+ contaminated = sum(len(m) - Counter(truth[x] for x in m).most_common(1)[0][1] for m in bad)
71
+ mixed: Counter[str] = Counter()
72
+ for m in clusters.values():
73
+ per_kind: dict[str, set[str]] = defaultdict(set)
74
+ for x in m:
75
+ per_kind[kind[truth[x]]].add(truth[x])
76
+ for k, ids in per_kind.items():
77
+ if len(ids) > 1:
78
+ mixed[k] += 1
79
+ return ClusterMetrics(
80
+ observations=len(assignment),
81
+ clusters=len(clusters),
82
+ b3_precision=precision,
83
+ b3_recall=recall,
84
+ b3_f1=2 * precision * recall / (precision + recall) if precision + recall else 0.0,
85
+ false_merge_clusters=len(bad),
86
+ contaminated=contaminated,
87
+ largest_bad_cluster=max((len(m) for m in bad), default=0),
88
+ by_kind_recall={k: sum(v) / len(v) for k, v in sorted(kind_r.items())},
89
+ mixed_by_kind=dict(sorted(mixed.items())),
90
+ )
91
+
92
+
93
+ def candidate_metrics(
94
+ pairs: Iterable[tuple[str, str]],
95
+ truth: Mapping[str, str],
96
+ kind: Mapping[str, str] | None = None,
97
+ ) -> dict:
98
+ """Pair recall and entity connectivity of a candidate set (design 03 §5), with
99
+ connectivity per organization kind when ``kind`` is given."""
100
+ pairs = set(pairs)
101
+ orgs: dict[str, list[str]] = defaultdict(list)
102
+ for obs, org in truth.items():
103
+ orgs[org].append(obs)
104
+ true_pairs = {
105
+ (a, b) if a < b else (b, a)
106
+ for members in orgs.values()
107
+ for a, b in combinations(members, 2)
108
+ }
109
+ found = true_pairs & pairs
110
+ by_obs: dict[str, set[tuple[str, str]]] = defaultdict(set)
111
+ for p in pairs:
112
+ if truth.get(p[0]) is not None and truth.get(p[0]) == truth.get(p[1]):
113
+ by_obs[truth[p[0]]].add(p)
114
+ is_connected = {
115
+ org: len(components(members, by_obs.get(org, ()))) == 1 for org, members in orgs.items()
116
+ }
117
+ connected = sum(is_connected.values())
118
+ per_kind: dict[str, list[bool]] = defaultdict(list)
119
+ for org, ok in is_connected.items():
120
+ if kind is not None:
121
+ per_kind[kind[org]].append(ok)
122
+ return {
123
+ "connectivity_by_kind": {k: sum(v) / len(v) for k, v in sorted(per_kind.items())},
124
+ "candidate_pairs": len(pairs),
125
+ "true_pairs": len(true_pairs),
126
+ "pair_recall": len(found) / len(true_pairs) if true_pairs else 1.0,
127
+ "entity_connectivity": connected / len(orgs) if orgs else 1.0,
128
+ }
129
+
130
+
131
+ @dataclass(frozen=True)
132
+ class Report:
133
+ benchmark: dict[str, Any]
134
+ clusters: ClusterMetrics
135
+ candidates: dict[str, Any]
136
+ reviews_opened: int
137
+ review_observations: int
138
+ seconds: dict[str, float]
139
+ run: dict[str, Any]
140
+
141
+ def as_dict(self) -> dict[str, Any]:
142
+ return asdict(self)
143
+
144
+ def summary(self) -> str:
145
+ c = self.clusters
146
+ return (
147
+ f"{self.benchmark['records']} records: B3 P {c.b3_precision:.4f} R {c.b3_recall:.4f} "
148
+ f"F1 {c.b3_f1:.4f} | false-merge clusters {c.false_merge_clusters} "
149
+ f"(contaminated {c.contaminated}, largest {c.largest_bad_cluster}) | "
150
+ f"reviews {self.reviews_opened}, {self.review_observations} obs in review | "
151
+ f"candidates: pair recall {self.candidates['pair_recall']:.3f}, connected "
152
+ f"{self.candidates['entity_connectivity']:.3f} | "
153
+ f"T_high {self.run.get('t_high', float('nan')):.1f} | "
154
+ f"{sum(self.seconds.values()):.1f}s"
155
+ )
156
+
157
+
158
+ def run_benchmark(
159
+ benchmark: Benchmark,
160
+ *,
161
+ profile: ResolutionProfile | None = None,
162
+ random_pairs: int = 300_000,
163
+ engine_kwargs: Mapping[str, Any] | None = None,
164
+ seed: int = 7,
165
+ adjudicator: Callable[[Thisness, dict[str, str], Any], Any] | None = None,
166
+ plan: Callable[[Thisness, dict[str, str], Any], Any] | None = None,
167
+ store: str = ":memory:",
168
+ ) -> tuple[Report, Thisness, dict[str, str]]:
169
+ """Ingest the benchmark into a fresh in-memory engine, resolve it as one bulk run,
170
+ and measure. Returns the report, the engine, and observation id -> true entity id.
171
+ ``adjudicator(engine, truth, benchmark)`` builds an adjudicator for the run, so a
172
+ simulated one can read the ground truth (design 05 §7). ``plan(engine, truth,
173
+ benchmark)`` builds a run plan the same way, such as a name-only plan (§6). ``store``
174
+ keeps the run in a DuckDB file, so the Workbench can open it afterwards."""
175
+ now = datetime(2026, 10, 7, tzinfo=UTC)
176
+ ids = IdFactory.seeded(seed)
177
+ entity_type = benchmark.entity_type
178
+ engine = Thisness(
179
+ store,
180
+ profiles=[profile or PROFILES[entity_type]()],
181
+ ids=ids,
182
+ clock=lambda: now,
183
+ random_pairs=random_pairs,
184
+ **dict(engine_kwargs or {}),
185
+ )
186
+ seconds: dict[str, float] = {}
187
+ started = time.perf_counter()
188
+ observations = [
189
+ engine.observe(
190
+ entity_type,
191
+ r.attributes,
192
+ {"type": getattr(r, "source", "benchmark"), "id": r.record_id},
193
+ ingested_at=now,
194
+ )
195
+ for r in benchmark.records
196
+ ]
197
+ engine.ingest(observations)
198
+ seconds["ingest"] = time.perf_counter() - started
199
+ truth = {
200
+ o.observation_id: benchmark.truth[r.record_id]
201
+ for o, r in zip(observations, benchmark.records, strict=True)
202
+ }
203
+
204
+ if adjudicator is not None:
205
+ engine.adjudicator = adjudicator(engine, truth, benchmark)
206
+ started = time.perf_counter()
207
+ run_plan = plan(engine, truth, benchmark) if plan is not None else None
208
+ result: RunResult = engine.resolve_many(entity_type, plan=run_plan)
209
+ seconds["fit_and_resolve"] = time.perf_counter() - started
210
+
211
+ assignment = engine.store.entities_of(list(truth))
212
+ clusters = cluster_metrics(assignment, truth, benchmark.kind)
213
+ candidates = (_candidates_of(engine, result, truth, benchmark.kind) if run_plan is None
214
+ else {"pair_recall": float("nan"), "entity_connectivity": float("nan")}) # fmt: skip
215
+ review_obs = sum(1 for o in result.outcomes.values() if o.outcome == "REQUIRES_REVIEW")
216
+ report = Report(
217
+ benchmark=benchmark.config,
218
+ clusters=clusters,
219
+ candidates=candidates,
220
+ reviews_opened=len(result.reviews),
221
+ review_observations=review_obs,
222
+ seconds=seconds,
223
+ run={k: v for k, v in result.stats.items() if k != "candidate_stage"},
224
+ )
225
+ if "adjudication" in report.run:
226
+ report.run["adjudication"] = {
227
+ **report.run["adjudication"],
228
+ "vs_truth": adjudication_accuracy(engine, truth, benchmark, result.run_id),
229
+ }
230
+ return report, engine, truth
231
+
232
+
233
+ def adjudication_accuracy(
234
+ engine: Thisness, truth: Mapping[str, str], benchmark: Benchmark, run_id: str | None
235
+ ) -> dict[str, Any]:
236
+ """The model's answers against the benchmark's truth (design 06 §10.6): what a real
237
+ model gets right, and how often it says "different" about one entity, the error that
238
+ holds a whole component in decide mode. A pair of different entities in one group
239
+ (a company group, a genus) is "related"; "different" is a right answer there too."""
240
+ from thisness.benchmark.oracle import benchmark_truth
241
+
242
+ truth_of = benchmark_truth(engine, truth)
243
+ confusion: Counter[tuple[str, str]] = Counter()
244
+ for judgment in engine.store.decisions_of_type("pair"):
245
+ if judgment.actor.kind != "agent" or (run_id is not None and judgment.run_id != run_id):
246
+ continue
247
+ answer = next((str(s.value) for s in judgment.scores if s.kind == "llm_decision"), None)
248
+ a, b = truth_of(judgment.left_id), truth_of(judgment.right_id)
249
+ if answer is None or a is None or b is None:
250
+ continue
251
+ group_a, group_b = benchmark.group.get(a), benchmark.group.get(b)
252
+ actual = ("same" if a == b else
253
+ "related" if group_a is not None and group_a == group_b else "different") # fmt: skip
254
+ confusion[(actual, answer)] += 1
255
+ right = {("same", "same"), ("different", "different"), ("related", "related"),
256
+ ("related", "different")} # fmt: skip
257
+ decided = {k: n for k, n in confusion.items() if k[1] != "unsure"}
258
+ same_pairs = sum(n for (actual, _), n in decided.items() if actual == "same")
259
+ other_pairs = sum(n for (actual, _), n in decided.items() if actual != "same")
260
+ total = sum(decided.values())
261
+
262
+ def rate(n: int, d: int) -> float | None:
263
+ return round(n / d, 4) if d else None
264
+
265
+ # Of the pairs that are one entity, answered "different" or "related": in decide
266
+ # mode each is a negative edge inside a true entity.
267
+ apart = sum(n for (a, x), n in decided.items() if a == "same" and x != "same")
268
+ # Of those, answered "different": the only answer that sets a negative edge.
269
+ split = sum(n for (a, x), n in decided.items() if a == "same" and x == "different")
270
+ # Of the pairs that are not one entity, answered "same": a false merge if applied.
271
+ merged = sum(n for (a, x), n in decided.items() if a != "same" and x == "same")
272
+ return {
273
+ "judged": sum(confusion.values()),
274
+ "unsure": sum(n for (_, answer), n in confusion.items() if answer == "unsure"),
275
+ "accuracy": rate(sum(n for k, n in decided.items() if k in right), total),
276
+ "wrongly_apart": rate(apart, same_pairs),
277
+ "wrongly_different": rate(split, same_pairs),
278
+ "wrongly_same": rate(merged, other_pairs),
279
+ "confusion": {f"{a}->{x}": n for (a, x), n in sorted(confusion.items())},
280
+ }
281
+
282
+
283
+ def _candidates_of(
284
+ engine: Thisness, result: RunResult, truth: Mapping[str, str], kind: Mapping[str, str]
285
+ ) -> dict:
286
+ from thisness.candidates import distinct_pairs, generate_all
287
+
288
+ profile = engine.profiles[result.stats.get("entity_type") or next(iter(engine.profiles))]
289
+ pairs = distinct_pairs(generate_all(engine.store, profile.candidate_generators, result.run_id))
290
+ return candidate_metrics(pairs, truth, kind)
@@ -0,0 +1,142 @@
1
+ """A simulated model that knows the benchmark's ground truth (design 05 §7). It answers
2
+ the adjudicator's pair questions and the review assistant's review questions.
3
+
4
+ It answers each pair from the truth and then errs in controlled ways, so the
5
+ pipeline and its policy can be measured at scale without spending: how many false
6
+ merges a model that is wrong 5% of the time causes in ``decide`` mode, or one that
7
+ folds related companies into each other 20% of the time. It reads only what a real
8
+ model would be sent, plus the record refs, and is deterministic for a seed.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import random
15
+ from collections import Counter
16
+ from collections.abc import Callable, Mapping
17
+ from itertools import combinations
18
+
19
+ from thisness.llm.providers import FunctionProvider
20
+ from thisness.llm.types import ModelRequest
21
+
22
+
23
+ def oracle_provider(
24
+ truth_of: Callable[[str], str | None],
25
+ related: Callable[[str, str], bool],
26
+ *,
27
+ error: float = 0.0,
28
+ unsure: float = 0.0,
29
+ fold_related: float = 0.0,
30
+ seed: int = 0,
31
+ local: bool = True,
32
+ ) -> FunctionProvider:
33
+ """``error``: answer the opposite of the truth (same for different and back).
34
+ ``unsure``: decline to answer. ``fold_related``: call related entities the same."""
35
+
36
+ def review(payload: dict) -> dict:
37
+ """A review recommendation: the records grouped by their true entity."""
38
+ relations = payload.get("relations") or ["related_to"]
39
+ records = payload["records"]
40
+ rng = random.Random(f"{seed}|{records[0]['record_id']}|review")
41
+ by_truth: dict[str | None, list[str]] = {}
42
+ for r in records:
43
+ truth = truth_of(r["record_id"])
44
+ by_truth.setdefault(truth if truth is not None else r["ref"], []).append(r["ref"])
45
+ groups = sorted(sorted(g) for g in by_truth.values())
46
+ current = sorted(sorted(g) for g in payload["current_groups"])
47
+ truths = [t for t in by_truth if t is not None]
48
+ x = rng.random()
49
+ if x < unsure:
50
+ return {"resolution": "unsure", "rationale": "simulated"}
51
+ if x < unsure + error:
52
+ groups = [sorted(r["ref"] for r in records)] if len(groups) > 1 else \
53
+ [[r["ref"]] for r in records] # fmt: skip
54
+ elif len(truths) == 2 and related(*truths) and rng.random() < fold_related:
55
+ groups = [sorted(r["ref"] for r in records)]
56
+ cited = [c["id"] for c in payload["comparisons"] if (c["weight"] or 0) > 0]
57
+ if len(groups) == 1:
58
+ out = {"resolution": "same"}
59
+ elif len(truths) == 2 and groups == current and related(*truths):
60
+ out = {"resolution": "related", "relation": relations[0]}
61
+ elif groups == current:
62
+ out = {"resolution": "different"}
63
+ else:
64
+ out = {"resolution": "groups", "groups": groups}
65
+ return {**out, "cited": cited, "reason_codes": ["OTHER"], "rationale": "simulated"}
66
+
67
+ def grouping(payload: dict) -> dict:
68
+ """A name-only grouping: items grouped by true entity, each labeled with it, so
69
+ separate batches converge on one label for one entity (design 01 §5.3)."""
70
+ items = payload["items"]
71
+ rng = random.Random(f"{seed}|{items[0]['record_id']}|{len(items)}|group")
72
+ truth = {it["ref"]: truth_of(it["record_id"]) or it["ref"] for it in items}
73
+ if fold_related:
74
+ # A model that folds related entities together: one label per related set.
75
+ keys = sorted(set(truth.values()))
76
+ merged = {k: k for k in keys}
77
+ for a, b in combinations(keys, 2):
78
+ if related(a, b) and random.Random(f"{seed}|{a}|{b}|fold").random() < fold_related:
79
+ merged[b] = merged[a]
80
+ truth = {ref: merged[t] for ref, t in truth.items()}
81
+ by_truth: dict[str, list[str]] = {}
82
+ for ref, t in truth.items():
83
+ x = rng.random()
84
+ if x < unsure:
85
+ continue
86
+ if x < unsure + error:
87
+ t = rng.choice(sorted(set(truth.values()))) # a wrong group, or the right one
88
+ by_truth.setdefault(t, []).append(ref)
89
+ return {"groups": [{"members": sorted(refs), "label": str(t)}
90
+ for t, refs in sorted(by_truth.items())]} # fmt: skip
91
+
92
+ def answer(request: ModelRequest) -> dict:
93
+ payload = json.loads(request.messages[0].content)
94
+ if "items" in payload:
95
+ return grouping(payload)
96
+ if "records" in payload:
97
+ return review(payload)
98
+ relations = payload.get("relations") or ["related_to"]
99
+ out = []
100
+ for p in payload["pairs"]:
101
+ a, b = p["a"]["ref"], p["b"]["ref"]
102
+ ta, tb = truth_of(a), truth_of(b)
103
+ rng = random.Random(f"{seed}|{a}|{b}")
104
+ if ta is not None and ta == tb:
105
+ correct = "same"
106
+ elif ta is not None and tb is not None and related(ta, tb):
107
+ correct = "related"
108
+ else:
109
+ correct = "different"
110
+ x = rng.random()
111
+ if x < unsure:
112
+ decision = "unsure"
113
+ elif x < unsure + error:
114
+ decision = "different" if correct == "same" else "same"
115
+ elif correct == "related" and rng.random() < fold_related:
116
+ decision = "same"
117
+ else:
118
+ decision = correct
119
+ positive = [c["id"] for c in p["comparisons"] if c["bits"] > 0]
120
+ negative = [c["id"] for c in p["comparisons"] if c["bits"] < 0]
121
+ out.append({
122
+ "pair": p["pair"], "decision": decision,
123
+ "relation": relations[0] if decision == "related" else None,
124
+ "supporting": positive if decision == "same" else negative,
125
+ "contradicting": negative if decision == "same" else positive,
126
+ "reason_codes": ["OTHER"], "rationale": "simulated",
127
+ }) # fmt: skip
128
+ return {"answers": out}
129
+
130
+ return FunctionProvider(answer, name="oracle", local=local)
131
+
132
+
133
+ def benchmark_truth(engine, truth: Mapping[str, str]) -> Callable[[str], str | None]:
134
+ """A record's true entity; an entity's is its members' majority truth."""
135
+
136
+ def truth_of(node: str) -> str | None:
137
+ if node in truth:
138
+ return truth[node]
139
+ members = [truth[m] for m in engine.members(node) if m in truth]
140
+ return Counter(members).most_common(1)[0][0] if members else None
141
+
142
+ return truth_of