Skip to content

Commit 2f5552c

Browse files
authored
Merge pull request #4 from Cellular-Semantics/issue-2-equivalent-to-and-ref-deltas
Keep EquivalentTo axioms and pair text changes (#2)
2 parents 5a5d2dd + a44f731 commit 2f5552c

19 files changed

Lines changed: 1467 additions & 33 deletions

File tree

‎.gitignore‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,3 +64,5 @@ Thumbs.db
6464
# uv
6565
.uv/
6666
uv.lock.bak
67+
68+
runs/CL_9999999

‎clara_workflow/agent_instructions.md‎

Lines changed: 61 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ Normalise ids as follows:
4141
The routing payload contains `targets`. Each target has one of these routes:
4242

4343
- `ntr` — new term bundle; contains decomposable text plus structural context
44+
- `text_revision` — revised prose on an existing term; decomposable
45+
- `refs_added` — prose on an existing term that did **not** change, with one or
46+
more references newly attached to it
4447
- `relationship` — a single atomic structural axiom to check
4548
- `synonym` — a single atomic synonym axiom to check
4649

@@ -56,6 +59,8 @@ Relevant target fields:
5659
- `term_level_candidate_refs`
5760
- for `ntr`: `textual_changes` (legacy alias `definition_changes`) and
5861
`relationship_changes`
62+
- for `text_revision`: `textual_changes`, plus `prior_value` on each change
63+
- for `refs_added`: `textual_changes` and `refs_added`
5964
- for `relationship` / `synonym`: `change`
6065

6166
### Contract rules
@@ -108,6 +113,32 @@ For `ntr` targets:
108113
4. Also take each `added` change from `relationship_changes` and convert it
109114
into one atomic `core` assertion.
110115

116+
### Route: `text_revision`
117+
118+
The prose changed on an existing term. Treat it like the textual half of an
119+
`ntr` target:
120+
121+
1. Decompose the `added` change's `value` into atomic assertions.
122+
2. Tag each `core` or `background` as above.
123+
124+
`prior_value` is the previous wording, provided as context only. Do not verify
125+
it, and do not report assertions that are unchanged between the two wordings as
126+
new claims — the reviewable content is the current `value`.
127+
128+
### Route: `refs_added`
129+
130+
The prose did **not** change; a reference was attached to it. The claim under
131+
review is that the *newly added* reference supports the existing text.
132+
133+
1. Decompose the `value` into atomic assertions as usual.
134+
2. Verify them **only** against `refs_added` — not the full ref list on the
135+
axiom. The pre-existing refs were justified when they were added.
136+
3. If every assertion is `uncertain` against the new refs, that is a real
137+
result: the added reference does not visibly support the definition. Say so
138+
in the report rather than falling back to the older refs.
139+
140+
Do not re-verify the definition against its whole reference set on this route.
141+
111142
### Route: `relationship`
112143

113144
For `relationship` targets:
@@ -144,14 +175,28 @@ Examples:
144175
- `synonym_related`:
145176
- "`{value}` is a related synonym of `{term_label}`."
146177

147-
For `equivalent_class` routed as a structural target:
178+
For `equivalent_class` routed as a structural target, `value` carries the bare
179+
axiom in Manchester syntax, e.g.
148180

149-
- If the routed change does not expose enough semantics to verbalise the
150-
logical definition faithfully, create one `core` assertion noting that the
151-
equivalent-class axiom for the term requires support.
152-
- If you cannot verify it from the exposed routing data, it is acceptable to
153-
leave that assertion `uncertain` with a note explaining that the routed
154-
payload does not provide enough structure for faithful verbalisation.
181+
```text
182+
buccal mucosa cell EquivalentTo keratinocyte and (part of some mouth mucosa)
183+
```
184+
185+
`EquivalentTo` states necessary **and sufficient** conditions, so it makes two
186+
claims. Emit **both** as separate `core` assertions:
187+
188+
- necessary — "Every `{term_label}` is a `{genus}` that `{differentia}`."
189+
- sufficient — "Every `{genus}` that `{differentia}` is a `{term_label}`."
190+
191+
The sufficient direction is the one editors get wrong and the one literature
192+
rarely states outright. Do not mark it `pass` on evidence that only supports
193+
the necessary direction, and do not collapse the two into a single assertion.
194+
If the axiom nests operators too deeply to verbalise faithfully, verbalise what
195+
you can and note the residue — do not silently drop a conjunct.
196+
197+
Only if `value` is absent or empty may you fall back to a single `core`
198+
assertion noting that the equivalent-class axiom requires support, left
199+
`uncertain` with a note that the payload exposed no axiom text.
155200

156201
## Reference scope per assertion
157202

@@ -165,7 +210,16 @@ Reference selection rules:
165210
`term_level_candidate_refs`.
166211
- For `relationship` targets, prefer `candidate_refs`; if empty, fall back to
167212
`term_level_candidate_refs`.
213+
- For `equivalent_class` changes, the refs are the **text definition's** refs.
214+
A logical axiom carries no dbxrefs of its own, so the producer fills
215+
`candidate_refs` from the definition — the logical definition formalises the
216+
prose, so the prose's evidence is what has to support it. Treat an empty list
217+
here as "the definition itself is uncited", not as a routing error.
168218
- For `synonym` targets, use `candidate_refs`.
219+
- For `text_revision` targets, use `candidate_refs`; if empty, fall back to
220+
`term_level_candidate_refs`.
221+
- For `refs_added` targets, use `refs_added` only. Never widen to the axiom's
222+
pre-existing refs.
169223
- Assume these ref lists were prefiltered upstream; only normalize case and
170224
slash formatting needed by the search tools.
171225

‎clara_workflow/stage1/README.md‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,38 @@ Any of the following, on either side (`added` or `removed`):
3737
Each of these carries its own `hasDbXref` list in the parser output; stage 2/3
3838
check that list when deciding whether the change is justified.
3939

40+
`definition_refs()` reads each term's definition dbxrefs out of the **head** edit
41+
file rather than the diff, because `robot diff` reports only changed axioms: a PR
42+
that adds a logical definition to an untouched term carries no definition axiom,
43+
yet the text definition it formalises is what justifies it. These land in
44+
`by_term[*].definition_refs`.
45+
46+
`equivalent_class` changes carry the axiom itself in `value`, as bare Manchester
47+
syntax (`buccal mucosa cell EquivalentTo keratinocyte and (part of some mouth
48+
mucosa)`). ROBOT's markdown diff already emits Manchester syntax; the parser just
49+
strips the markdown links off the labels.
50+
51+
## Paired text changes (`text_deltas()`)
52+
53+
ROBOT reports an annotated axiom as an independent `Removed` + `Added` bullet,
54+
and an OWL axiom's identity includes its annotations. So attaching one dbxref to
55+
an untouched definition is indistinguishable from a rewrite unless the two sides
56+
are paired and their values compared.
57+
58+
`text_deltas()` does that pairing for `text_def` / `comment`, giving each a
59+
status:
60+
61+
| `status` | Meaning | What stages 2/3 should check |
62+
|-------------|---------|---|
63+
| `revised` | The prose changed. | The new prose, against the axiom's refs. |
64+
| `refs_only` | The prose is byte-identical; only refs changed. | The existing prose, against `refs_added` **only**. |
65+
| `added` | No matching removed side. | The prose, as a new claim. |
66+
| `removed` | No matching added side. | Nothing — removals aren't justified by refs. |
67+
68+
Without this split, a ref-only edit re-verifies an unchanged definition against
69+
its entire reference list, and the actual claim — "this newly added paper
70+
supports the existing definition" — never gets asked.
71+
4072
## Not reviewed (kept in output for traceability, but skipped by `reviewable_changes()`)
4173

4274
| `kind` | Why ignored |

‎clara_workflow/stage1/__init__.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,23 @@
1111
CONTENT_KINDS,
1212
DECOMPOSABLE_KINDS,
1313
decomposable_changes,
14+
definition_refs,
1415
parse_diff_markdown,
1516
reviewable_changes,
1617
summarise_by_term,
18+
text_deltas,
19+
TextDelta,
1720
)
1821

1922
__all__ = [
2023
"Change",
2124
"CONTENT_KINDS",
2225
"DECOMPOSABLE_KINDS",
2326
"decomposable_changes",
27+
"definition_refs",
2428
"parse_diff_markdown",
2529
"reviewable_changes",
2630
"summarise_by_term",
31+
"text_deltas",
32+
"TextDelta",
2733
]

‎clara_workflow/stage1/extract.py‎

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,11 @@
2727
from clara_workflow.stage1.parse import (
2828
Change,
2929
decomposable_changes,
30+
definition_refs,
3031
parse_diff_markdown,
3132
reviewable_changes,
3233
summarise_by_term,
34+
text_deltas,
3335
)
3436

3537

@@ -67,8 +69,13 @@ def extract(
6769
right_ref: str,
6870
edit_file: str,
6971
robot: str = "robot",
70-
) -> list[Change]:
71-
"""Resolve two refs against `edit_file`, run robot diff, parse, return changes."""
72+
) -> tuple[list[Change], dict[str, list[str]]]:
73+
"""Resolve two refs against `edit_file`, run robot diff, parse.
74+
75+
Returns the parsed changes plus the head-state definition refs per term,
76+
which is where a logical definition's justification lives when the text
77+
definition itself wasn't touched by the PR.
78+
"""
7279
with tempfile.TemporaryDirectory() as td:
7380
tdp = Path(td)
7481
left = tdp / "left.owl"
@@ -77,7 +84,10 @@ def extract(
7784
_git_show(repo, left_ref, edit_file, left)
7885
_git_show(repo, right_ref, edit_file, right)
7986
_robot_diff(left, right, diff_md, robot=robot)
80-
return parse_diff_markdown(diff_md.read_text())
87+
return (
88+
parse_diff_markdown(diff_md.read_text()),
89+
definition_refs(right.read_text()),
90+
)
8191

8292

8393
def main(argv: list[str] | None = None) -> int:
@@ -94,7 +104,7 @@ def main(argv: list[str] | None = None) -> int:
94104
print(f"robot executable not found: {args.robot}", file=sys.stderr)
95105
return 2
96106

97-
changes = extract(
107+
changes, head_definition_refs = extract(
98108
repo=args.repo,
99109
left_ref=args.left,
100110
right_ref=args.right,
@@ -109,10 +119,16 @@ def main(argv: list[str] | None = None) -> int:
109119
"changes": [_change_to_dict(c) for c in changes],
110120
"reviewable": [_change_to_dict(c) for c in reviewable],
111121
"decomposable": [_change_to_dict(c) for c in decomposable],
122+
# Removed/added text axioms paired so a ref-only edit is distinguishable
123+
# from a rewrite; consumed by the router to pick a route.
124+
"text_deltas": [dataclasses.asdict(d) for d in text_deltas(changes)],
112125
"by_term": {
113126
tid: {
114127
**{k: v for k, v in entry.items() if k != "changes"},
115128
"changes": [_change_to_dict(c) for c in entry["changes"]],
129+
# Refs on the term's definition as it stands at the head ref —
130+
# present whether or not this PR touched the definition.
131+
"definition_refs": head_definition_refs.get(tid, []),
116132
}
117133
for tid, entry in summarise_by_term(changes).items()
118134
},

‎clara_workflow/stage1/parse.py‎

Lines changed: 136 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,18 @@ def _iter_links(s: str) -> Iterator[tuple[str, str]]:
120120
yield m.group("text"), m.group("url")
121121

122122

123+
def _bare_axiom(body: str) -> str:
124+
"""Reduce a diff bullet to a bare Manchester-syntax axiom.
125+
126+
`robot diff --format markdown --labels true` already emits class expressions
127+
in Manchester syntax; the labels just arrive wrapped in markdown links.
128+
Dropping the links leaves the axiom as an editor would write it, e.g.
129+
130+
buccal mucosa cell EquivalentTo keratinocyte and (part of some mouth mucosa)
131+
"""
132+
return re.sub(r"\s+", " ", _LINK_RE.sub(lambda m: m.group("text"), body)).strip()
133+
134+
123135
def _parse_bullet(line: str, term_id: str, term_label: str, side: str) -> Change:
124136
"""Parse one top-level `- ...` bullet into a Change.
125137
@@ -165,9 +177,11 @@ def _parse_bullet(line: str, term_id: str, term_label: str, side: str) -> Change
165177

166178
# EquivalentClasses.
167179
if " EquivalentClasses " in body or "EquivalentTo" in body:
180+
# The logical definition is the claim to verify, so carry the axiom
181+
# itself rather than dropping it (issue #2).
168182
return Change(
169183
term_id=term_id, term_label=term_label, side=side,
170-
kind="equivalent_class", raw=raw,
184+
kind="equivalent_class", value=_bare_axiom(body), raw=raw,
171185
)
172186

173187
# Annotation-style: [subj] [pred](IRI) <value>
@@ -326,3 +340,124 @@ def decomposable_changes(changes: list[Change]) -> list[Change]:
326340
without an intermediate decomposition step.
327341
"""
328342
return [c for c in reviewable_changes(changes) if c.kind in DECOMPOSABLE_KINDS]
343+
344+
345+
# --- text change pairing ---------------------------------------------------
346+
347+
@dataclass
348+
class TextDelta:
349+
"""One decomposable text axiom, paired across the removed/added sides.
350+
351+
`robot diff` reports an annotated axiom as an independent removed + added
352+
bullet, and an OWL axiom's identity includes its annotations. So adding a
353+
dbxref to an untouched definition looks exactly like a definition rewrite
354+
unless the two sides are paired and their values compared.
355+
356+
status:
357+
revised — the prose changed (verify the new prose)
358+
refs_only — the prose is byte-identical; only the refs changed (verify
359+
the existing prose against `refs_added`, nothing else)
360+
added — no matching removed side (new axiom on an existing term)
361+
removed — no matching added side (axiom deleted)
362+
"""
363+
term_id: str
364+
term_label: str
365+
kind: str # text_def | comment
366+
status: str # revised | refs_only | added | removed
367+
value: str | None = None # the added (current) prose
368+
prior_value: str | None = None # the removed (previous) prose
369+
refs: list[str] = field(default_factory=list) # refs on the added side
370+
refs_added: list[str] = field(default_factory=list)
371+
refs_removed: list[str] = field(default_factory=list)
372+
373+
374+
def text_deltas(changes: list[Change]) -> list[TextDelta]:
375+
"""Pair removed/added decomposable text axioms per (term, kind).
376+
377+
Obsoleted terms and non-content kinds are excluded, matching
378+
`reviewable_changes`.
379+
"""
380+
grouped: dict[tuple[str, str], dict[str, list[Change]]] = {}
381+
for c in reviewable_changes(changes):
382+
if c.kind not in DECOMPOSABLE_KINDS:
383+
continue
384+
sides = grouped.setdefault((c.term_id, c.kind), {"added": [], "removed": []})
385+
sides[c.side].append(c)
386+
387+
def _delta(kind: str, added: Change | None, removed: Change | None) -> TextDelta:
388+
src = added or removed
389+
assert src is not None
390+
if added is not None and removed is not None:
391+
status = "refs_only" if added.value == removed.value else "revised"
392+
else:
393+
status = "added" if added is not None else "removed"
394+
before = set(removed.refs) if removed is not None else set()
395+
after = set(added.refs) if added is not None else set()
396+
return TextDelta(
397+
term_id=src.term_id,
398+
term_label=src.term_label,
399+
kind=kind,
400+
status=status,
401+
value=added.value if added is not None else None,
402+
prior_value=removed.value if removed is not None else None,
403+
refs=list(added.refs) if added is not None else [],
404+
refs_added=sorted(after - before),
405+
refs_removed=sorted(before - after),
406+
)
407+
408+
out: list[TextDelta] = []
409+
for (_term_id, kind), sides in grouped.items():
410+
added = list(sides["added"])
411+
removed = list(sides["removed"])
412+
413+
# Pair identical prose first — those are ref-only edits, and matching
414+
# them by value keeps a genuine rewrite from stealing the partner.
415+
for a in list(added):
416+
match = next((r for r in removed if r.value == a.value), None)
417+
if match is not None:
418+
added.remove(a)
419+
removed.remove(match)
420+
out.append(_delta(kind, a, match))
421+
422+
# Then pair what's left positionally; a term rarely carries more than
423+
# one definition, so this only matters for multi-comment edits.
424+
while added and removed:
425+
out.append(_delta(kind, added.pop(0), removed.pop(0)))
426+
427+
out.extend(_delta(kind, a, None) for a in added)
428+
out.extend(_delta(kind, None, r) for r in removed)
429+
430+
return out
431+
432+
# --- head-state definition refs -------------------------------------------
433+
434+
# One axiom per line in ROBOT's functional syntax, so a definition's dbxrefs are
435+
# whatever `hasDbXref` annotations sit to the left of the IAO_0000115 predicate.
436+
_DEF_PREDICATE_RE = re.compile(r"obo:IAO_0000115 obo:([A-Za-z]+_\d+)")
437+
_XREF_VALUE_RE = re.compile(r'oboInOwl:hasDbXref "([^"]+)"')
438+
439+
440+
def definition_refs(owl_text: str) -> dict[str, list[str]]:
441+
"""Map term CURIE -> refs on that term's definition, from an edit file.
442+
443+
`robot diff` only reports axioms that changed, so a PR that adds a logical
444+
definition to an untouched term carries no definition axiom at all. The
445+
references that justify a logical definition are the ones on the text
446+
definition it formalises, which means reading them out of the head state
447+
rather than the diff.
448+
449+
Parsed line-wise on purpose: a nested-paren regex misses definitions whose
450+
dbxref values contain brackets (e.g. Wikipedia URLs with parenthesised
451+
disambiguators).
452+
"""
453+
out: dict[str, list[str]] = {}
454+
for line in owl_text.splitlines():
455+
stripped = line.strip()
456+
if not stripped.startswith("AnnotationAssertion("):
457+
continue
458+
match = _DEF_PREDICATE_RE.search(stripped)
459+
if match is None:
460+
continue
461+
term_id = match.group(1).replace("_", ":", 1)
462+
out[term_id] = _XREF_VALUE_RE.findall(stripped[: match.start()])
463+
return out

0 commit comments

Comments
 (0)