@@ -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+
123135def _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