-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtasks.py
More file actions
1439 lines (1294 loc) · 64.5 KB
/
Copy pathtasks.py
File metadata and controls
1439 lines (1294 loc) · 64.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""Turn one extraction record into the Label Studio tasks that review it.
One emitter per task kind, all producing the same envelope: every task in a
project carries every key that project's config reads, with exactly one gate
non-empty. The envelope is built from `config.contract()` rather than assembled by
hand, so a key the config adds appears here as an empty array rather than as a
task the editor cannot render.
Two identifiers do the regeneration work, and the distinction between them is what
makes review survive a corrected record:
review_key the ADDRESS paper|kind|class|local_id|slot
content_hash WHAT WAS ASKED, a digest of the answer-bearing payload only
Same address and hash means the answer stands. A changed hash re-asks. A vanished
address orphans the answer. The hash deliberately excludes descriptors, rendered
prose and offsets, so correcting a `Group.name` does not re-ask a dozen questions
whose substance did not move.
"""
from __future__ import annotations
import hashlib
import json
import re
from collections.abc import Mapping
from dataclasses import dataclass, field
from difflib import SequenceMatcher
from pathlib import Path
from typing import Any
import config
import record as record_module
import spec
import staging
import tables
import upstream # noqa: F401 (puts the schema submodule on sys.path)
import text_index # noqa: E402
from record import Record
#: Below this, a contrast task says so: the record and the parsed analysis it was
#: matched to do not agree on every token of their names, so the marked rows may be
#: another contrast's. Every link across the baseline papers scores exactly 1.0,
#: which is why the score is printed only when it is not -- a line reading
#: "name match 1.00" on every task is a diagnostic nobody can act on.
WEAK_MATCH = 1.0
#: And the blind spot that leaves. `tables.name_score` divides token overlap by the
#: *smaller* set, deliberately, so a short name still matches a longer one -- with the
#: consequence that a strict subset scores a perfect 1.00 and never trips WEAK_MATCH. On
#: `YwwKWoEFwY3G` the parse named three analyses `Encoding`, `Maintenance` and `Retrieval`
#: while the record splits each by frequency band, so five records competed for three
#: parses at 1.00 apiece and the reviewer was told nothing. `name_overlap` is the Jaccard
#: that separates them: 0.14-0.17 on those three, 1.0 on every other link in the corpus.
WEAK_OVERLAP = 0.5
#: Statuses meaning "no reported result rests on this".
WITHOUT_COORDINATES = frozenset({"no_table", "no_contrast", "no_coordinates"})
def digest(payload: Any) -> str:
"""The content hash: what was asked, not how it was rendered."""
blob = json.dumps(payload, sort_keys=True, ensure_ascii=False, default=str)
return hashlib.sha256(blob.encode("utf-8")).hexdigest()[:16]
@dataclass
class Report:
counts: dict[str, int] = field(default_factory=dict)
spans: int = 0
choices: int = 0
skipped: list[str] = field(default_factory=list)
def add(self, kind: str) -> None:
self.counts[kind] = self.counts.get(kind, 0) + 1
@property
def total(self) -> int:
return sum(self.counts.values())
def summary(self) -> str:
per_kind = ", ".join(f"{kind} {n}" for kind, n in sorted(self.counts.items()))
return f"{self.total} tasks ({per_kind}); {self.spans} spans, {self.choices} choices"
class Exporter:
"""Every task for one paper, in project order."""
def __init__(
self,
body: Mapping[str, Any],
normalized: str,
paper_id: str,
identifiers: Mapping[str, Any],
paper_url: str,
*,
coordinate_counts: Mapping[str, int] | None = None,
coordinates_only: bool = False,
) -> None:
self.record = Record(body)
self.normalized = normalized
self.paper_id = paper_id
self.identifiers = identifiers or {}
self.paper_url = paper_url
self.counts = dict(coordinate_counts or {})
self.coordinates_only = coordinates_only
self.text_hash = text_index.text_hash(normalized)
self.sections = text_index.build_sections(normalized)
self.report = Report()
self.model_version = spec.PREDICTION_VERSION
self.tasks: dict[str, list[dict[str, Any]]] = {p.name: [] for p in spec.PROJECTS}
# Filled by `load_tables`, which is optional: a paper with no synced pubget
# source still exports every other family.
self.pubget_dir: Path | None = None
self.manifest: dict[str, dict[str, Any]] = {}
self.parsed: dict[str, list[dict[str, Any]]] = {}
self.local_of: dict[str, str] = {}
self.pubget_of: dict[str, list[str]] = {}
self.links = tables.Links()
self._rendered: dict[str, Any] = {}
# -- the envelope ------------------------------------------------------
def _blank(self, kind: spec.Kind) -> dict[str, Any]:
"""A task of this kind with every contracted key present and empty.
Building outwards from the contract rather than listing keys per emitter is
what keeps the two in step: the previous exporter had to remember to write
`entities=[], entity_table=[], entity_rows=[], model=[], terms=[]` into
every structure task, and a key it forgot rendered as a missing block with
no error.
"""
project = spec.PROJECT_OF[kind.name]
data = {
key: config.default_for(shape) for key, shape in config.contract(project).items()
}
data.update(
paper_id=self.paper_id,
stage=kind.stage,
task_kind=kind.name,
paper_url=self.paper_url,
paper_title=self.paper_id,
paper_citation=self._citation(),
# Deliberately outside the content hash: a re-staged text does not
# change the question, it changes where the answer's evidence lives.
# Carrying it makes `data` differ, so the sync takes its "display
# refreshed" branch -- answers kept, predictions rewritten -- instead of
# the unchanged short-circuit that would leave every stored offset
# addressing a text that is no longer served.
paper_text_hash=self.text_hash,
priority="n/a",
coordinate_status="not_applicable",
)
return data
def _emit(
self,
kind: spec.Kind,
address: str,
asked: Any,
data: dict[str, Any],
results: list[dict[str, Any]] | None = None,
) -> None:
data["review_key"] = f"{self.paper_id}|{kind.name}|{address}"
data["content_hash"] = digest(asked)
task: dict[str, Any] = {"data": data}
if results:
task["predictions"] = [
{"model_version": self.model_version, "result": results}
]
self.tasks[spec.PROJECT_OF[kind.name].name].append(task)
self.report.add(kind.name)
def _citation(self) -> str:
parts = [self.paper_id]
if self.identifiers.get("pmid"):
parts.append(f"pmid {self.identifiers['pmid']}")
if self.identifiers.get("doi"):
parts.append(str(self.identifiers["doi"]))
return " · ".join(parts)
# -- predictions -------------------------------------------------------
def _spans(
self,
kind: spec.Kind,
sets: list[Any],
label: str,
object_index: int = 0,
) -> list[dict[str, Any]]:
"""The highlights that support one object, as Label Studio results.
Two constraints hold at once here, and both fail silently when broken.
The result type is the *control's*, not the layer's: Label Studio reads a
result as `value[from_name.valueType]`, so a naming layer -- a `<Taxonomy>`
-- finds nothing under `labels` and throws while rendering the region the
reviewer just clicked. Hence the branch on `kind.naming`, which is the same
predicate `blocks.py` uses to choose the tag.
And `id` addresses a *region*, not a result. Label Studio keys areas by it,
and a repeated id is not a second region: the later span is folded into the
first and its offsets are dropped. `object_index` is what keeps ids apart
across the callers that loop one object at a time.
"""
control = spec.instance(kind.name, "spans")
value_key = "taxonomy" if kind.naming else "labels"
results = []
for set_index, evidence_set in enumerate(sets):
for span_index, span in enumerate(evidence_set.get("spans", [])):
# A task must never ship an offset that does not address the text we
# are about to serve.
staging.verify(self.normalized, span)
results.append(
{
"id": f"{control}_{object_index}_{set_index}_{span_index}",
"from_name": control,
"to_name": spec.PAPER,
"type": "taxonomy" if kind.naming else "labels",
"value": {
"start": span["start_char"],
"end": span["end_char"],
"text": span["text"],
# A Taxonomy value is a list of *paths*; these layers
# are one level deep, so the path is the name alone.
value_key: [[label]] if kind.naming else [label],
},
}
)
self.report.spans += 1
return results
def _choice(self, control: str, values: list[str]) -> dict[str, Any]:
self.report.choices += 1
return {
"from_name": control,
"to_name": spec.PAPER,
"type": "choices",
"value": {"choices": values},
}
def _number(self, control: str, value: int) -> dict[str, Any]:
self.report.choices += 1
return {
"from_name": control,
"to_name": spec.PAPER,
"type": "number",
"value": {"number": value},
}
@staticmethod
def _palette(index: int) -> str:
return spec.PALETTE[index % len(spec.PALETTE)]
def _label(self, kind: spec.Kind, value: str, index: int) -> dict[str, str]:
"""One entry of a task's `$labels`, keyed the way its own control reads it.
`<Labels>` colours a highlight from `background`. A `<Taxonomy>` ignores that
key -- its dynamic children carry `color` and nothing else through -- so a
naming layer whose labels only say `background` draws every span uncoloured
and the reviewer cannot tell which object a highlight belongs to.
"""
colour = self._palette(index)
item = {"value": value, "background": colour}
if kind.naming:
item["color"] = colour
return item
#: Extraction stores the source's wording; these controls offer a fixed
#: vocabulary. Matching is on a normalized stem, so "between subjects",
#: "between-subject" and "Between Subject" all land on one value. A wording the
#: map cannot place is left unselected rather than guessed -- a wrong
#: pre-selection is worse than none, because the reviewer may not re-read it.
_TERM_TYPES = {"categor": "categorical", "continu": "continuous"}
_VARIATION = {
"within": "within_subject",
"between": "between_subject",
"both": "both",
"unstated": "not_reported",
"not_reported": "not_reported",
}
@staticmethod
def _match(value: str, table: dict[str, str]) -> str | None:
folded = value.strip().lower().replace("-", " ").replace("_", " ")
for stem, canonical in table.items():
if stem.replace("_", " ") in folded:
return canonical
return None
# -- value -------------------------------------------------------------
#: Where a previewed value stops being context and becomes prose. The corpus's
#: median extracted value is 12 characters and its 90th percentile is 100; the
#: longest is 1203, and one `definition` printed whole is taller than the
#: instance the preview exists to show.
PREVIEW_CHARS = 120
def _entity_preview(
self, found: record_module.Field
) -> tuple[str, list[dict[str, str]]]:
"""The instance a field was taken from: its other values, then its links.
Keyed on `local_id` alone, not on the class: a nested value object carries
its own class name and the owning instance's id, so `Measure.type` and
`Analysis.spatial_scope` are two fields of one Analysis and belong in one
preview. The dotted path is what says where each row sits.
A link row renders the target's descriptor rather than its `local_id`,
because `grp_1` cannot be checked against the paper and `grp_1 -- patients .
n=20` can.
"""
owner = self.record.class_of.get(found.local_id, found.class_name)
rows: list[dict[str, str]] = []
for other in self.record.fields:
if other.local_id != found.local_id or other.path == found.path:
continue
value = (
record_module.display(other.node["value"])
if "value" in other.node
else record_module.NOT_REPORTED
)
if len(value) > self.PREVIEW_CHARS:
value = value[: self.PREVIEW_CHARS].rstrip() + "..."
rows.append({"label": other.path, "meta": value})
links = 0
for reference in self.record.references:
if reference.local_id != found.local_id:
continue
target = record_module.target_class(self.record.classes, reference.attribute)
described = [
self.record.descriptor(target, local_id) if target else local_id
for local_id in reference.targets
]
links += 1
rows.append(
{
"label": reference.slot,
"meta": "-> " + (" · ".join(described) or "nothing"),
}
)
head = (
f"The rest of {owner} {found.local_id} · "
f"{len(rows) - links} field(s), {links} link(s)"
)
return head, rows
def emit_value(self) -> None:
"""One task per field of one entity, including fields marked not_reported.
Per field rather than per entity: an entity task bundles 13-25 judgements
behind a single verdict, so a reviewer either accepts all of them at once or
opens a long form, and the answer needs an index path to address. One field
is one decision.
A `not_reported` field gets a task of its own because "the paper does state
this" is a finding the extractor cannot make about itself.
No evidence is quoted. Whatever the extractor found is already highlighted
in the paper pane, where the sentences either side of it are what settle
whether it supports the value -- and an excerpt hides exactly those.
"""
kind = spec.BY_NAME["value"]
for found in self.record.fields:
if found.structural:
continue
value = (
record_module.display(found.node["value"])
if "value" in found.node
else record_module.NOT_REPORTED
)
section = ""
if found.sets:
first = found.sets[0]["spans"][0]["start_char"]
section = text_index.section_path(self.sections, first) or ""
span_total = sum(len(s.get("spans", [])) for s in found.sets)
# Two labels, one per kind of support, rather than one per evidence set.
# Direct vs inferred is a property of the passage and the reviewer
# decides it while drawing, so it belongs on the label; a perRegion
# control stays hidden until a span is clicked, which is too late.
predicted = (
"direct support"
if found.node.get("value_source") == "reported"
else "inferred support"
)
results = self._spans(kind, found.sets, predicted)
handle = (
f"{found.class_name} {found.local_id}" if found.local_id else found.class_name
)
description = (found.attribute.get("description") or "").strip().split(". ")[0][:90]
# Display only, and deliberately outside the content hash below: a
# corrected sibling changes what this task shows, not what it asks.
head, preview = self._entity_preview(found)
data = self._blank(kind)
data.update(
entity_class=found.class_name,
local_id=found.local_id,
field_path=found.path,
priority=found.priority,
llm_status=found.status,
evidence_status=found.evidence_status,
row_count=span_total,
coordinate_status=self.record.object_status(found.local_id, self.counts),
entity_head=head,
entity=preview,
labels=[
{"value": name, "background": self._palette(index)}
for index, (name, _hint) in enumerate(spec.SUPPORT_KINDS)
],
**{
kind.gate: [
{
"label": f"{handle} · {found.path}",
"meta": " · ".join(
filter(
None,
[
description,
f"{span_total} span(s)" if found.sets else "no evidence",
section,
],
)
),
"body": value,
}
]
},
)
self._emit(
kind,
f"{found.class_name}|{found.local_id}|{found.path}",
(value, found.status, found.evidence_status),
data,
results,
)
# -- relationship ------------------------------------------------------
def emit_relationship(self) -> None:
"""One grid per association slot: rows are source objects, columns targets.
Judging the whole assignment at once is what makes an unused target visible
as an empty column, which no per-source-object task can show.
"""
kind = spec.BY_NAME["relationship"]
by_slot: dict[tuple[str, str], list[record_module.Reference]] = {}
for reference in self.record.references:
if reference.structural:
continue
by_slot.setdefault((reference.class_name, reference.slot), []).append(reference)
for (class_name, slot), references in by_slot.items():
attribute = references[0].attribute
target = record_module.target_class(self.record.classes, attribute)
if not target:
continue
candidates = self.record.instances.get(target) or []
if not candidates:
# Reported, never silently absent: a slot with no candidates is a
# finding about the record, and a task asserting emptiness is not.
self.report.skipped.append(
f"{class_name}.{slot} -> {target}: no candidates extracted"
)
continue
multivalued = bool(attribute.get("multivalued"))
columns = [
{"value": self.record.descriptor(target, candidate), "alias": candidate}
for candidate in candidates
]
if not multivalued:
# A single-select needs somewhere to say "none of these". Without it
# an unlinked source object is indistinguishable from one nobody got
# to, and a radio cannot be cleared once clicked.
columns.append({"value": "no link", "alias": "none"})
rows: list[dict[str, Any]] = []
results: list[dict[str, Any]] = []
anomalies: list[str] = []
for index, reference in enumerate(references):
# The name half of the descriptor, which is what a reviewer scans;
# the id goes underneath, where it can be matched to a highlight.
heading = self.record.descriptor(class_name, reference.local_id).split(" -- ")[-1]
control = spec.instance(kind.name, "row" if multivalued else "one", index)
chosen = [t for t in reference.targets if t in candidates]
if chosen:
results.append(self._choice(control, chosen))
if attribute.get("required") and not reference.targets:
anomalies.append(f"- **{heading}** is required to link but has none")
for missing in reference.targets:
if missing not in candidates:
anomalies.append(
f"- **{heading}** links to `{missing}`, which was never extracted"
)
rows.append(
{"label": heading, "meta": reference.local_id, "local_id": reference.local_id}
)
data = self._blank(kind)
data.update(
rel_slot=f"{class_name}.{slot}",
entity_class=class_name,
row_count=len(rows),
coordinate_status="yes"
if any(
self.record.object_status(r.local_id, self.counts) == "yes"
for r in references
)
else "unrelated",
rows=rows if multivalued else [],
rows_single=[] if multivalued else rows,
columns=columns,
# Only hard anomalies, and only when there are any: an empty list
# renders no panel rather than an empty one.
anomalies=[{"text": "\n".join(anomalies)}] if anomalies else [],
# The span layer's labels are not the grid's columns. "no link" is a
# column but not a label -- no passage warrants an absence -- and a
# label's text becomes a button, so a 77-character descriptor is
# clipped to something that fits one.
labels=[
{"value": column["value"][:60], "alias": column["alias"]}
for column in columns
if column["alias"] != "none"
],
**{
kind.gate: [
{
"label": f"Which {target}{'s' if multivalued else ''} "
f"does each {class_name} use?",
"meta": f"{class_name}.{slot} -> {target} · "
f"{'many' if multivalued else 'one'} per {class_name}",
"body": (attribute.get("description") or "").strip(),
}
]
},
)
self._emit(
kind,
f"{class_name}.{slot}",
[(r.local_id, sorted(r.targets)) for r in references],
data,
results,
)
# -- entities ----------------------------------------------------------
def emit_entities(self) -> None:
"""Stage 0. One task per class: is this the right set of instances?
The only place an invented Group can be rejected -- a value task judges its
fields and a relationship task judges its links, and both presuppose it
exists.
"""
kind = spec.BY_NAME["entities"]
for class_name in self.record.entity_classes:
if class_name in record_module.INVENTORY_EXCLUDED:
continue
ids = self.record.instances.get(class_name) or []
legend, rows, labels, results = [], [], [], []
for index, local_id in enumerate(ids):
source, sets = self.record.existence_evidence(class_name, local_id)
descriptor = self.record.descriptor(class_name, local_id)
references = (
f"referenced by {self.record.inbound.get(local_id, 0)} link(s)"
+ (f" · evidence from {source}" if source else " · no evidence")
)
legend.append(
{"id": local_id, "descriptor": descriptor, "references": references}
)
rows.append({"label": local_id, "meta": f"{descriptor} · {references}"})
labels.append(self._label(kind, local_id, index))
results += self._spans(kind, sets, local_id, index)
bearing = sum(
1 for i in ids if self.record.object_status(i, self.counts) == "yes"
)
data = self._blank(kind)
data.update(
entity_class=class_name,
row_count=len(ids),
coordinate_status="yes" if bearing else "unrelated",
legend=legend,
rows=rows,
labels=labels,
**{
kind.gate: [
{
"label": f"{class_name} · "
+ (f"{len(ids)} extracted" if ids else "none extracted"),
"meta": f"{bearing} tied to a reported result",
"body": "",
}
]
},
)
self._emit(kind, class_name, sorted(ids), data, results)
# -- model -------------------------------------------------------------
def emit_model(self) -> None:
"""One task per ModelEstimation: is this the right term list?
Per model rather than per analysis. On the measured records a model serves
up to four analyses, so reviewing its terms per analysis would review them
four times.
"""
kind = spec.BY_NAME["model"]
for local_id, model in self.record.models().items():
rows, labels, results = [], [], []
for index, term in enumerate(model.get("terms") or []):
if not isinstance(term, Mapping):
continue
name = record_module.unwrap(term.get("name")) or term.get("local_id", "?")
levels = [lv for lv in term.get("levels") or [] if isinstance(lv, Mapping)]
# Only the facts no control on the card repeats. The card carries the
# name, the type and the scope as controls, so restating them here
# said the same thing three ways.
facts = [
record_module.unwrap(term.get("source_definition")),
f"unit {record_module.unwrap(term.get('unit'))}"
if record_module.unwrap(term.get("unit"))
else "",
f"{len(levels)} level(s) declared" if levels else "",
]
rows.append(
{
"label": name,
"meta": " · ".join(filter(None, facts))
or "no definition, unit or levels recorded",
"local_id": term.get("local_id", ""),
"levels": [
{"label": record_module.unwrap(lv.get("level")) or "(unnamed)",
"level": record_module.unwrap(lv.get("level")) or "(unnamed)"}
for lv in levels
],
}
)
labels.append(self._label(kind, f"term: {name}", index))
node = term.get("name") if isinstance(term.get("name"), Mapping) else {}
results += self._spans(
kind,
((node.get("evidence") or {}).get("sets")) or [],
f"term: {name}",
index,
)
matched = self._match(record_module.unwrap(term.get("type")), self._TERM_TYPES)
if matched:
results.append(
self._choice(spec.instance(kind.name, "type", index), [matched])
)
matched = self._match(
record_module.unwrap(term.get("variation_level")), self._VARIATION
)
if matched:
results.append(
self._choice(spec.instance(kind.name, "scope", index), [matched])
)
for level_index, level in enumerate(levels):
# `order` arrives as an extraction node, not a bare number, so it
# is unwrapped before it can be pre-filled -- and a non-numeric
# one is left unselected rather than guessed at.
try:
number = int(float(record_module.unwrap(level.get("order"))))
except (TypeError, ValueError):
continue
results.append(
self._number(
spec.instance(kind.name, "order", index, level_index), number
)
)
# The stage this model was fitted on belongs on the card: its terms are
# this model's too, so a reviewer judging whether the list is complete
# has to know which columns are already accounted for below.
inputs = [i for i in model.get("inputs_from") or [] if isinstance(i, str)]
facts = [
record_module.unwrap(model.get("model_family")),
record_module.unwrap(model.get("model_type")),
record_module.unwrap(model.get("estimator")),
record_module.unwrap(model.get("software")),
record_module.unwrap(model.get("stage")),
f"fitted on {', '.join(inputs)}" if inputs else "",
]
data = self._blank(kind)
data.update(
local_id=local_id,
entity_class="ModelEstimation",
row_count=len(rows),
coordinate_status=self.record.model_status(local_id, self.counts),
rows=rows,
labels=labels,
**{
kind.gate: [
{
"label": f"{local_id} · {len(rows)} terms",
"meta": " · ".join(filter(None, facts)),
"body": "",
}
]
},
)
self._emit(
kind,
local_id,
[(r["local_id"], r["label"], [lv["level"] for lv in r["levels"]]) for r in rows],
data,
results,
)
# -- the coordinate tables ---------------------------------------------
def load_tables(
self, pubget_dir: Path | None, stage1: Path | None, table_map: Path | None
) -> None:
"""The coordinate tables and the stage-1 split, for the contrast project.
Every failure is reported and none raises. A paper whose pubget source was
never synced still exports value, relationship and structure tasks; it
simply gets no table tasks, and its contrast tasks say so on the face of the
task rather than rendering an empty grid that reads like an analysis with no
results.
"""
if pubget_dir is None or not Path(pubget_dir).is_dir():
self.report.skipped.append(
f"no pubget source at {pubget_dir}: no table tasks, and contrast tasks "
"carry no grid"
)
return
self.pubget_dir = Path(pubget_dir)
self.manifest = tables.read_manifest(self.pubget_dir.parent.parent)
if not self.manifest:
self.report.skipped.append(
f"no tables.jsonl under {self.pubget_dir.parent.parent}: no table tasks"
)
if stage1 and Path(stage1).is_file():
self.parsed = tables.load_stage1(Path(stage1))
else:
self.report.skipped.append(
f"no stage-1 parse at {stage1}: no table tasks, and no rows can be "
"attributed to a contrast"
)
if table_map and Path(table_map).is_file():
self.local_of = json.loads(Path(table_map).read_text(encoding="utf-8"))
for pubget_id, local_id in self.local_of.items():
self.pubget_of.setdefault(local_id, []).append(pubget_id)
elif self.parsed:
self.report.skipped.append(
f"no table map at {table_map}: a contrast cannot be tied to its table"
)
encoded = {
analysis.get("local_id", ""): (
record_module.unwrap(analysis.get("name")),
[t for t in analysis.get("tables") or [] if isinstance(t, str)],
)
for analysis in self.record.analyses()
}
directions = {
analysis.get("local_id", ""): {
record_module.unwrap(cell.get("direction"))
for cell in ((analysis.get("effect") or {}).get("cells") or [])
if isinstance(cell, Mapping)
}
for analysis in self.record.analyses()
}
self.links = tables.link_analyses(encoded, self.parsed, self.local_of,
directions=directions)
for local_id in self.links.unmatched_records:
self.report.skipped.append(
f"contrast {local_id}: no parsed analysis matched, so no rows are marked"
)
for table_id, position in self.links.unmatched_siblings:
name = self.parsed[table_id][position].get("name") or "(unnamed)"
self.report.skipped.append(
f"table {table_id} analysis #{position + 1} ({name}): parsed but never "
"encoded -- the missed_analysis case"
)
def _table(self, table_id: str):
"""One rendered table, cached: a contrast task and its table task share it."""
if table_id not in self._rendered:
entry = self.manifest.get(table_id)
self._rendered[table_id] = (
tables.read_table(
self.pubget_dir,
entry["data_file"],
label=entry["table_label"],
caption=entry["caption"],
)
if entry and self.pubget_dir and entry.get("data_file")
else None
)
return self._rendered[table_id]
def _sibling_meta(self, table_id: str, position: int, owner: Mapping[int, int]) -> str:
rows = sum(1 for holder in owner.values() if holder == position)
points = len(self.parsed[table_id][position].get("points") or [])
encoded = [
local_id
for local_id, (tid, pos, _score) in self.links.matched.items()
if tid == table_id and pos == position
]
return " · ".join(
[
f"{points} point(s)",
f"{rows} row(s) attributed",
f"encoded as {encoded[0]}" if encoded else "NOT ENCODED as any analysis",
]
)
def emit_table(self) -> None:
"""Stage 0 over one coordinate table: is this the right set of analyses?
The judgement nothing else in the pipeline can make. Stage 1 splits each
table into analyses with one model call, and that split is where
over-splitting, merging, missed analyses and misattributed rows happen --
but every downstream task addresses an analysis that already exists, so none
of them can say the set is wrong.
Emitted for every table any analysis references as well as every table stage
1 parsed, so a coordinate table the parser found nothing in still gets asked
about. That is the `not_analyses` and `missed_analysis` case, and skipping it
would make the two indistinguishable from a table nobody looked at.
"""
kind = spec.BY_NAME["table"]
referenced = {
pubget_id
for analysis in self.record.analyses()
for local_id in analysis.get("tables") or []
for pubget_id in self.pubget_of.get(local_id, [])
}
for table_id in sorted(set(self.parsed) | referenced):
table = self._table(table_id)
siblings = self.parsed.get(table_id, [])
owner, contested = tables.attribute_rows(table, siblings)
names = [s.get("name") or "(unnamed)" for s in siblings]
local_id = self.local_of.get(table_id, table_id)
entry = self.manifest.get(table_id) or {}
data = self._blank(kind)
data.update(
local_id=local_id,
entity_class="Table",
table_id=table_id,
row_count=len(siblings),
coordinate_status=(
"yes" if any(s.get("points") for s in siblings)
else "no_coordinates" if siblings else "no_contrast"
),
table_html=tables.render_table_html(
table, owner=owner, contested=contested, missing=local_id
),
# No trailing "+ new analysis" slot: one label cannot stand for two
# missed analyses, and both spans came back wearing it. The control
# is a naming layer, so a reviewer types the name instead.
labels=[
self._label(kind, f"analysis: {name}", index)
for index, name in enumerate(names)
],
rows=[
{
"label": f"#{index + 1} · {name}",
"meta": self._sibling_meta(table_id, index, owner),
}
for index, name in enumerate(names)
],
**{
kind.gate: [
{
"label": f"{entry.get('table_label') or local_id}"
f" · {len(siblings)} "
f"analys{'is' if len(siblings) == 1 else 'es'} parsed",
# Not the caption: the grid below prints it, and printing
# it twice on one screen is most of what made this task
# long. What a reviewer needs before scanning is what the
# attribution could not settle.
"meta": tables.attribution_note(table, siblings, owner, contested),
"body": "",
}
]
},
)
# The split and the rows it rests on, and nothing else. Re-rendering the
# grid or fixing a caption must not re-ask a segmentation question whose
# substance did not move.
self._emit(
kind,
local_id,
[
(name, sorted(row for row, holder in owner.items() if holder == index))
for index, name in enumerate(names)
],
data,
)
def _contrast_grid(self, analysis: Mapping[str, Any]) -> tuple[str, str, str]:
"""(table_id, rendered grid, the line naming which parse this came off).
An analysis with no matched parse still gets its table, unhighlighted, and a
line saying so. Rendering nothing would be indistinguishable from an analysis
whose rows simply were not found.
"""
local_id = analysis.get("local_id", "")
matched = self.links.matched.get(local_id)
if matched:
table_id, position, score = matched
table = self._table(table_id)
sibling = self.parsed[table_id][position]
# The resolved attribution, not raw coordinate matching. Both views of one
# table must agree: a paper reporting the same peak under two contrasts
# makes raw matching claim five rows here while the table task -- which
# resolves inside section blocks -- attributes three, and a reviewer shown
# both would have no way to tell which was lying.
owner, contested = tables.attribute_rows(table, self.parsed.get(table_id) or [])
rows = [row for row, holder in owner.items() if holder == position]
shared = {row: holders for row, holders in contested.items() if position in holders}
note = "" if table else "The table could not be read."
if table and not rows:
note = (
"No row was attributed to this analysis. Either the parser missed "
"them or this contrast is reported somewhere other than this table."
)
label = (self.manifest.get(table_id) or {}).get("table_label") or table_id
line = (
f"{label} · analysis {position + 1} of {len(self.parsed[table_id])} · "
f"{len(sibling.get('points') or [])} points"
)
# Two ways a link can be weak, and the second is invisible to the score: a
# record name that strictly contains the parse's scores 1.00 while sharing
# little of it. Both get the same caution, because both mean the same thing to
# a reviewer -- the marked rows may be another contrast's.
overlap = tables.name_overlap(
record_module.unwrap(analysis.get("name")) or "", sibling.get("name") or "")
if score < WEAK_MATCH or overlap < WEAK_OVERLAP:
line += (f" · weak name match {score:.2f}/{overlap:.2f}, "
"check the rows are this contrast's")
return (
table_id,
tables.render_table_html(
table, highlight=rows, contested=shared, note=note, missing=local_id
),
line,
)
referenced = [t for t in analysis.get("tables") or [] if isinstance(t, str)]
pubget_ids = [pid for t in referenced for pid in self.pubget_of.get(t, [])]
table_id = pubget_ids[0] if pubget_ids else ""
return (
table_id,
tables.render_table_html(
self._table(table_id) if table_id else None,
note="No parsed analysis matched this record, so no row is marked.",
missing=referenced[0] if referenced else "this analysis",
),
"No stage-1 analysis matched this record: the rows it rests on are unknown, "
"which is itself worth reporting on the table task.",
)
def _row_meta(
self, term: Mapping[str, Any], level: str, celled: list[str], levels: int
) -> str:
"""Which unsigned option this row can take, and why.
The five options are one static control -- Label Studio choices live in the
config, and `visibleWhen` reads only choices, never task data -- so the
conditional part is a line of data above them. It is worth spending, because
which unsigned value a row is entitled to is not a property of the row: it
follows from the term (a cell with no level cannot sit on both sides of
anything) and from the *other* rows (a factor celled at every level is an