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.
- thisness/__init__.py +13 -0
- thisness/_canonical.py +41 -0
- thisness/adjudicate.py +173 -0
- thisness/assistant.py +246 -0
- thisness/benchmark/__init__.py +0 -0
- thisness/benchmark/__main__.py +87 -0
- thisness/benchmark/data/README.md +13 -0
- thisness/benchmark/data/uk/README.md +7 -0
- thisness/benchmark/data/uk/ch.parquet +0 -0
- thisness/benchmark/data/uk/gleif.parquet +0 -0
- thisness/benchmark/data/uk/gleif_rr.parquet +0 -0
- thisness/benchmark/data/wfo_fixture.csv.gz +0 -0
- thisness/benchmark/evaluate.py +290 -0
- thisness/benchmark/oracle.py +142 -0
- thisness/benchmark/organizations.py +338 -0
- thisness/benchmark/people.py +716 -0
- thisness/benchmark/taxa.py +453 -0
- thisness/benchmark/uk.py +788 -0
- thisness/browse.py +148 -0
- thisness/candidates.py +171 -0
- thisness/canonicalize.py +146 -0
- thisness/cli.py +546 -0
- thisness/comparisons/__init__.py +0 -0
- thisness/comparisons/base.py +59 -0
- thisness/comparisons/organization.py +227 -0
- thisness/comparisons/person.py +249 -0
- thisness/comparisons/taxon.py +232 -0
- thisness/consensus.py +424 -0
- thisness/core/__init__.py +106 -0
- thisness/core/base.py +14 -0
- thisness/core/components.py +53 -0
- thisness/core/decisions.py +185 -0
- thisness/core/evidence.py +58 -0
- thisness/core/identity.py +134 -0
- thisness/core/invocation.py +49 -0
- thisness/core/ledger.py +49 -0
- thisness/core/observation.py +86 -0
- thisness/core/review.py +34 -0
- thisness/core/run.py +266 -0
- thisness/core/types.py +51 -0
- thisness/dossier.py +554 -0
- thisness/edges.py +92 -0
- thisness/engine.py +1108 -0
- thisness/fellegi_sunter.py +369 -0
- thisness/ids.py +176 -0
- thisness/labels.py +87 -0
- thisness/llm/__init__.py +23 -0
- thisness/llm/client.py +219 -0
- thisness/llm/prices.py +31 -0
- thisness/llm/privacy.py +57 -0
- thisness/llm/prompts/.keep +0 -0
- thisness/llm/prompts/adjudicate_pairs.v1.md +22 -0
- thisness/llm/prompts/adjudicate_pairs.v2.md +20 -0
- thisness/llm/prompts/group_names.v1.md +14 -0
- thisness/llm/prompts/group_names.v2.md +13 -0
- thisness/llm/prompts/recommend_review.v1.md +21 -0
- thisness/llm/prompts/recommend_review.v2.md +21 -0
- thisness/llm/prompts.py +83 -0
- thisness/llm/providers/__init__.py +7 -0
- thisness/llm/providers/anthropic.py +60 -0
- thisness/llm/providers/function.py +24 -0
- thisness/llm/providers/openai_compatible.py +90 -0
- thisness/llm/providers/replay.py +55 -0
- thisness/llm/types.py +57 -0
- thisness/name_only.py +450 -0
- thisness/normalize/__init__.py +0 -0
- thisness/normalize/organization.py +283 -0
- thisness/normalize/person.py +417 -0
- thisness/normalize/taxon.py +317 -0
- thisness/policy.py +54 -0
- thisness/profiles/__init__.py +0 -0
- thisness/profiles/base.py +306 -0
- thisness/profiles/data/organization_starter.json +184 -0
- thisness/profiles/data/person_starter.json +240 -0
- thisness/profiles/data/taxon_starter.json +189 -0
- thisness/profiles/organization.py +100 -0
- thisness/profiles/person.py +129 -0
- thisness/profiles/taxon.py +129 -0
- thisness/py.typed +0 -0
- thisness/relationships.py +113 -0
- thisness/reviews.py +294 -0
- thisness/scoring.py +196 -0
- thisness/starter.py +108 -0
- thisness/storage/__init__.py +21 -0
- thisness/storage/_commit.py +632 -0
- thisness/storage/duckdb/__init__.py +3 -0
- thisness/storage/duckdb/invariants.py +243 -0
- thisness/storage/duckdb/migrations/0001_initial.sql +243 -0
- thisness/storage/duckdb/migrations/0002_pair_constraints.sql +13 -0
- thisness/storage/duckdb/migrations/0003_model_invocations.sql +28 -0
- thisness/storage/duckdb/store.py +1615 -0
- thisness/storage/errors.py +49 -0
- thisness/storage/protocol.py +136 -0
- thisness/training.py +151 -0
- thisness/ui/__init__.py +5 -0
- thisness/ui/app.py +538 -0
- thisness/ui/static/THIRD_PARTY.md +203 -0
- thisness/ui/static/app.css +408 -0
- thisness/ui/static/app.js +135 -0
- thisness/ui/static/icons/adjustments-horizontal.svg +23 -0
- thisness/ui/static/icons/alert-triangle.svg +17 -0
- thisness/ui/static/icons/arrow-back-up.svg +16 -0
- thisness/ui/static/icons/arrows-join.svg +17 -0
- thisness/ui/static/icons/arrows-split.svg +18 -0
- thisness/ui/static/icons/briefcase.svg +18 -0
- thisness/ui/static/icons/building.svg +22 -0
- thisness/ui/static/icons/cake.svg +17 -0
- thisness/ui/static/icons/chart-dots.svg +20 -0
- thisness/ui/static/icons/chevron-down.svg +15 -0
- thisness/ui/static/icons/chevron-right.svg +15 -0
- thisness/ui/static/icons/circle-check.svg +16 -0
- thisness/ui/static/icons/circle-dashed.svg +22 -0
- thisness/ui/static/icons/circle-x.svg +16 -0
- thisness/ui/static/icons/clock.svg +16 -0
- thisness/ui/static/icons/database.svg +17 -0
- thisness/ui/static/icons/external-link.svg +17 -0
- thisness/ui/static/icons/file-text.svg +19 -0
- thisness/ui/static/icons/filter.svg +15 -0
- thisness/ui/static/icons/fingerprint.svg +19 -0
- thisness/ui/static/icons/git-branch.svg +20 -0
- thisness/ui/static/icons/git-merge.svg +19 -0
- thisness/ui/static/icons/hash.svg +18 -0
- thisness/ui/static/icons/history.svg +16 -0
- thisness/ui/static/icons/home.svg +17 -0
- thisness/ui/static/icons/id.svg +19 -0
- thisness/ui/static/icons/layout-sidebar-right.svg +16 -0
- thisness/ui/static/icons/leaf.svg +16 -0
- thisness/ui/static/icons/link.svg +17 -0
- thisness/ui/static/icons/list-check.svg +20 -0
- thisness/ui/static/icons/mail.svg +16 -0
- thisness/ui/static/icons/map-pin.svg +16 -0
- thisness/ui/static/icons/message-question.svg +19 -0
- thisness/ui/static/icons/phone.svg +15 -0
- thisness/ui/static/icons/player-play.svg +15 -0
- thisness/ui/static/icons/plus.svg +16 -0
- thisness/ui/static/icons/scale.svg +19 -0
- thisness/ui/static/icons/search.svg +16 -0
- thisness/ui/static/icons/sparkles.svg +15 -0
- thisness/ui/static/icons/timeline-event.svg +18 -0
- thisness/ui/static/icons/user.svg +16 -0
- thisness/ui/static/icons/users.svg +18 -0
- thisness/ui/static/icons/world.svg +19 -0
- thisness/ui/static/icons/x.svg +16 -0
- thisness/ui/static/theme.css +525 -0
- thisness/ui/static/vendor/Sortable.min.js +2 -0
- thisness/ui/static/vendor/htmx.min.js +1 -0
- thisness/ui/static/vendor/inter-latin-ext-wght-normal.woff2 +0 -0
- thisness/ui/static/vendor/inter-latin-wght-normal.woff2 +0 -0
- thisness/ui/templates/_macros.html +27 -0
- thisness/ui/templates/_panel_head.html +4 -0
- thisness/ui/templates/base.html +53 -0
- thisness/ui/templates/entities.html +48 -0
- thisness/ui/templates/entity.html +71 -0
- thisness/ui/templates/entity_tab.html +106 -0
- thisness/ui/templates/llm.html +32 -0
- thisness/ui/templates/model.html +35 -0
- thisness/ui/templates/not_found.html +4 -0
- thisness/ui/templates/panel_candidate.html +23 -0
- thisness/ui/templates/panel_decision.html +41 -0
- thisness/ui/templates/panel_entity.html +37 -0
- thisness/ui/templates/panel_invocation.html +16 -0
- thisness/ui/templates/panel_observation.html +22 -0
- thisness/ui/templates/relationships.html +32 -0
- thisness/ui/templates/review.html +106 -0
- thisness/ui/templates/reviews.html +32 -0
- thisness/ui/templates/runs.html +20 -0
- thisness/ui/templates/search_results.html +7 -0
- thisness-0.1.0.dist-info/METADATA +183 -0
- thisness-0.1.0.dist-info/RECORD +172 -0
- thisness-0.1.0.dist-info/WHEEL +4 -0
- thisness-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|