Skip to content

Commit 9318f26

Browse files
committed
#336 - Introduce Annotation class
- Introduced Annotation class with default begin/end 0 - Update reference data accordingly - Fixed several issues with sorting/ordering annotations
1 parent 9f138a7 commit 9318f26

File tree

8 files changed

+194
-87
lines changed

8 files changed

+194
-87
lines changed

cassis/cas.py

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,12 @@
1919
TYPE_NAME_FS_LIST,
2020
TYPE_NAME_SOFA,
2121
FeatureStructure,
22+
Annotation,
2223
Type,
2324
TypeCheckError,
2425
TypeSystem,
2526
TypeSystemMode,
27+
is_annotation,
2628
)
2729

2830
_validator_optional_string = validators.optional(validators.instance_of(str))
@@ -171,9 +173,15 @@ def type_index(self) -> Dict[str, SortedKeyList]:
171173
return self._indices
172174

173175
def add_annotation_to_index(self, annotation: FeatureStructure):
176+
"""Adds a feature structure to the type index for this view.
177+
178+
The index accepts both annotation-like FS (with begin/end) and
179+
arbitrary feature structures. Sorting is performed by the shared
180+
`_sort_func` which duck-types annotation instances.
181+
"""
174182
self._indices[annotation.type.name].add(annotation)
175183

176-
def get_all_annotations(self) -> List[FeatureStructure]:
184+
def get_all_annotations(self) -> List[Annotation]:
177185
"""Gets all the annotations in this view.
178186
179187
Returns:
@@ -334,6 +342,8 @@ def add(self, annotation: FeatureStructure, keep_id: Optional[bool] = True):
334342
if hasattr(annotation, "sofa"):
335343
annotation.sofa = self.get_sofa()
336344

345+
# Add to the index. The view index accepts any FeatureStructure;
346+
# `_sort_func` will duck-type annotation-like objects when sorting.
337347
self._current_view.add_annotation_to_index(annotation)
338348

339349
@deprecation.deprecated(details="Use add()")
@@ -387,7 +397,7 @@ def remove_annotation(self, annotation: FeatureStructure):
387397
self.remove(annotation)
388398

389399
@deprecation.deprecated(details="Use annotation.get_covered_text()")
390-
def get_covered_text(self, annotation: FeatureStructure) -> str:
400+
def get_covered_text(self, annotation: Annotation) -> str:
391401
"""Gets the text that is covered by `annotation`.
392402
393403
Args:
@@ -413,7 +423,7 @@ def select(self, type_: Union[Type, str]) -> List[FeatureStructure]:
413423
t = type_ if isinstance(type_, Type) else self.typesystem.get_type(type_)
414424
return self._get_feature_structures(t)
415425

416-
def select_covered(self, type_: Union[Type, str], covering_annotation: FeatureStructure) -> List[FeatureStructure]:
426+
def select_covered(self, type_: Union[Type, str], covering_annotation: Annotation) -> List[FeatureStructure]:
417427
"""Returns a list of covered annotations.
418428
419429
Return all annotations that are covered
@@ -439,7 +449,7 @@ def select_covered(self, type_: Union[Type, str], covering_annotation: FeatureSt
439449
result.append(annotation)
440450
return result
441451

442-
def select_covering(self, type_: Union[Type, str], covered_annotation: FeatureStructure) -> List[FeatureStructure]:
452+
def select_covering(self, type_: Union[Type, str], covered_annotation: Annotation) -> List[FeatureStructure]:
443453
"""Returns a list of annotations that cover the given annotation.
444454
445455
Return all annotations that are covering. This can be potentially be slow.
@@ -465,7 +475,7 @@ def select_covering(self, type_: Union[Type, str], covered_annotation: FeatureSt
465475
if c_begin >= annotation.begin and c_end <= annotation.end:
466476
yield annotation
467477

468-
def select_all(self) -> List[FeatureStructure]:
478+
def select_all(self) -> List[Annotation]:
469479
"""Finds all feature structures in this Cas
470480
471481
Returns:
@@ -834,8 +844,8 @@ def _copy(self) -> "Cas":
834844

835845

836846
def _sort_func(a: FeatureStructure) -> Tuple[int, int, int]:
837-
d = a.__slots__
838-
if "begin" in d and "end" in d:
839-
return a.begin, a.end, id(a)
840-
else:
841-
return sys.maxsize, sys.maxsize, id(a)
847+
if is_annotation(a):
848+
return a.begin, a.end, a.xmiID if getattr(a, "xmiID", None) is not None else id(a)
849+
850+
# Non-annotation feature structures are sorted after annotations using large sentinels
851+
return sys.maxsize, sys.maxsize, a.xmiID if getattr(a, "xmiID", None) is not None else id(a)

cassis/typesystem.py

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,23 @@ def __repr__(self):
500500
return str(self)
501501

502502

503+
@attr.s(slots=True, hash=False, eq=True, order=True, repr=False)
504+
class Annotation(FeatureStructure):
505+
"""Concrete base class for annotation instances.
506+
507+
Generated types that represent (subtypes of) `uima.tcas.Annotation` will
508+
inherit from this class so that static typing can rely on a nominal base
509+
providing `begin` and `end`.
510+
"""
511+
512+
begin: int = attr.ib(default=0)
513+
end: int = attr.ib(default=0)
514+
515+
516+
def is_annotation(fs: FeatureStructure) -> bool:
517+
return hasattr(fs, "begin") and isinstance(fs.begin, int) and hasattr(fs, "end") and isinstance(fs.end, int)
518+
519+
503520
@attr.s(slots=True, eq=False, order=False, repr=False)
504521
class Feature:
505522
"""A feature defines one attribute of a feature structure"""
@@ -572,15 +589,44 @@ class Type:
572589
def __attrs_post_init__(self):
573590
"""Build the constructor that can create feature structures of this type"""
574591
name = _string_to_valid_classname(self.name)
575-
fields = {feature.name: attr.ib(default=None, repr=(feature.name != "sofa")) for feature in self.all_features}
592+
593+
# Determine whether this type is (transitively) a subtype of uima.tcas.Annotation
594+
def _is_annotation_type(t: "Type") -> bool:
595+
cur = t
596+
while cur is not None:
597+
if cur.name == TYPE_NAME_ANNOTATION:
598+
return True
599+
cur = cur.supertype
600+
return False
601+
602+
# When inheriting from our concrete Annotation base, do not redeclare
603+
# the 'begin' and 'end' features as fields; they are already present.
604+
fields = {}
605+
for feature in self.all_features:
606+
if feature.name in {"begin", "end"} and _is_annotation_type(self):
607+
# skip - Annotation base provides these
608+
continue
609+
fields[feature.name] = attr.ib(default=None, repr=(feature.name != "sofa"))
576610
fields["type"] = attr.ib(default=self)
577611

578612
# We assign this to a lambda to make it lazy
579613
# When creating large type systems, almost no types are used so
580614
# creating them on the fly is on average better
581-
self._constructor_fn = lambda: attr.make_class(
582-
name, fields, bases=(FeatureStructure,), slots=True, eq=False, order=False
583-
)
615+
bases = (Annotation,) if _is_annotation_type(self) else (FeatureStructure,)
616+
617+
def _make_fs_class():
618+
cls = attr.make_class(name, fields, bases=bases, slots=True, eq=False, order=False)
619+
# Ensure generated FS classes are hashable. When a class defines an
620+
# __eq__ (inherited or generated) but no __hash__, Python makes
621+
# instances unhashable. We want FeatureStructure-based instances to
622+
# be usable as dict/set keys (they are keyed by xmiID), so assign the
623+
# base FeatureStructure.__hash__ implementation to the generated
624+
# class if it doesn't already provide one.
625+
if getattr(cls, "__hash__", None) is None:
626+
cls.__hash__ = FeatureStructure.__hash__
627+
return cls
628+
629+
self._constructor_fn = _make_fs_class
584630

585631
def __call__(self, **kwargs) -> FeatureStructure:
586632
"""Creates an feature structure of this type

cassis/util.py

Lines changed: 70 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
import csv
22
from collections import defaultdict
3-
from functools import cmp_to_key
43
from io import IOBase, StringIO
5-
from typing import Dict, Iterable, Set
4+
from typing import Any, Dict, Iterable, Set
65

76
from cassis import Cas
8-
from cassis.typesystem import FEATURE_BASE_NAME_SOFA, TYPE_NAME_ANNOTATION, FeatureStructure, Type, is_array
7+
from cassis.typesystem import FEATURE_BASE_NAME_SOFA, TYPE_NAME_ANNOTATION, FeatureStructure, Type, is_annotation, is_array
98

109
_EXCLUDED_FEATURES = {FEATURE_BASE_NAME_SOFA}
1110
_NULL_VALUE = "<NULL>"
@@ -74,7 +73,7 @@ def _render_feature_structure(
7473
) -> []:
7574
row_data = [fs_id_to_anchor.get(fs.xmiID)]
7675

77-
if max_covered_text > 0 and _is_annotation_fs(fs):
76+
if max_covered_text > 0 and is_annotation(fs):
7877
covered_text = fs.get_covered_text()
7978
if covered_text and len(covered_text) >= max_covered_text:
8079
prefix = covered_text[0 : (max_covered_text // 2)]
@@ -143,7 +142,19 @@ def _generate_anchors(
143142
for t in types_sorted:
144143
type_ = cas.typesystem.get_type(t)
145144
feature_structures = all_feature_structures_by_type[type_.name]
146-
feature_structures.sort(key=cmp_to_key(lambda a, b: _compare_fs(type_, a, b)))
145+
# Sort deterministically using a stable key function. We avoid using
146+
# the comparator-based approach to prevent unpredictable comparisons
147+
# between mixed types during lexicographic tuple comparisons.
148+
feature_structures.sort(
149+
key=lambda fs: (
150+
0,
151+
fs.begin,
152+
fs.end,
153+
str(_feature_structure_hash(type_, fs)),
154+
)
155+
if is_annotation(fs)
156+
else (1, None, None, str(_feature_structure_hash(type_, fs)))
157+
)
147158

148159
for fs in feature_structures:
149160
add_index_mark = mark_indexed and fs in indexed_feature_structures
@@ -159,7 +170,7 @@ def _generate_anchors(
159170
def _generate_anchor(fs: FeatureStructure, add_index_mark: bool) -> str:
160171
anchor = fs.type.name.rsplit(".", 2)[-1] # Get the short type name (no package)
161172

162-
if _is_annotation_fs(fs):
173+
if is_annotation(fs):
163174
anchor += f"[{fs.begin}-{fs.end}]"
164175

165176
if add_index_mark:
@@ -171,7 +182,7 @@ def _generate_anchor(fs: FeatureStructure, add_index_mark: bool) -> str:
171182
return anchor
172183

173184

174-
def _is_primitive_value(value: any) -> bool:
185+
def _is_primitive_value(value: Any) -> bool:
175186
return type(value) in (int, float, bool, str)
176187

177188

@@ -182,65 +193,62 @@ def _is_array_fs(fs: FeatureStructure) -> bool:
182193
return is_array(fs.type)
183194

184195

185-
def _is_annotation_fs(fs: FeatureStructure) -> bool:
186-
return hasattr(fs, "begin") and isinstance(fs.begin, int) and hasattr(fs, "end") and isinstance(fs.end, int)
187-
188-
189-
def _compare_fs(type_: Type, a: FeatureStructure, b: FeatureStructure) -> int:
190-
if a is b:
191-
return 0
192-
193-
# duck-typing check if something is a annotation - if yes, try sorting by offets
194-
fs_a_is_annotation = _is_annotation_fs(a)
195-
fs_b_is_annotation = _is_annotation_fs(b)
196-
if fs_a_is_annotation != fs_b_is_annotation:
197-
return -1
198-
if fs_a_is_annotation and fs_b_is_annotation:
199-
begin_cmp = a.begin - b.begin
200-
if begin_cmp != 0:
201-
return begin_cmp
202-
203-
begin_cmp = b.end - a.end
204-
if begin_cmp != 0:
205-
return begin_cmp
206-
207-
# Alternative implementation
208-
# Doing arithmetics on the hash value as we have done with the offsets does not work because the hashes do not
209-
# provide a global order. Hence, we map all results to 0, -1 and 1 here.
210-
fs_hash_a = _feature_structure_hash(type_, a)
211-
fs_hash_b = _feature_structure_hash(type_, b)
212-
if fs_hash_a == fs_hash_b:
213-
return 0
214-
return -1 if fs_hash_a < fs_hash_b else 1
215-
216-
217196
def _feature_structure_hash(type_: Type, fs: FeatureStructure):
218-
hash_ = 0
197+
# For backward compatibility keep a function that returns a stable string
198+
# representation of the FS contents. This is used as a deterministic
199+
# tie-breaker when sorting. We avoid returning complex nested tuples to
200+
# keep comparisons simple and stable across original and deserialized CASes.
201+
def _render_val(v):
202+
if v is None:
203+
return "<NULL>"
204+
if type(v) in (int, float, bool, str):
205+
return str(v)
206+
if _is_array_fs(v):
207+
# Join element representations with '|'
208+
return "[" + ",".join(_render_val(e) for e in (v.elements or [])) + "]"
209+
# Feature structure reference
210+
try:
211+
if is_annotation(v):
212+
return f"{v.type.name}@{v.begin}-{v.end}"
213+
else:
214+
return f"{v.type.name}"
215+
except Exception:
216+
return str(v)
217+
219218
if _is_array_fs(fs):
220-
return len(fs.elements) if fs.elements else 0
219+
return _render_val(fs.elements or [])
221220

222-
# Should be possible to get away with not sorting here assuming that all_features returns the features always in
223-
# the same order
221+
parts: list[str] = []
224222
for feature in type_.all_features:
225223
if feature.name == FEATURE_BASE_NAME_SOFA:
226224
continue
227-
228-
feature_value = getattr(fs, feature.name)
229-
230-
if _is_array_fs(feature_value):
231-
if feature_value.elements is not None:
232-
for element in feature_value.elements:
233-
hash_ = _feature_value_hash(feature_value, hash_)
225+
parts.append(_render_val(getattr(fs, feature.name)))
226+
return "|".join(parts)
227+
228+
229+
def _normalize_feature_value(value: Any):
230+
"""Return a stable, comparable representation for a feature value.
231+
232+
Primitives are returned as-is. Feature structure references are normalized
233+
to a tuple containing the referenced type name and offsets if the target
234+
is an annotation. Arrays are represented as tuples of normalized elements.
235+
"""
236+
# Use tagged tuples to guarantee consistent types and deterministic
237+
# ordering during comparisons. This avoids runtime TypeErrors when
238+
# different kinds of values (None, tuple, primitive) would otherwise
239+
# be compared directly.
240+
if value is None:
241+
return ("N",)
242+
if type(value) in (int, float, bool, str):
243+
return ("P", value)
244+
if _is_array_fs(value):
245+
return ("A",) + tuple(_normalize_feature_value(e) for e in (value.elements or []))
246+
# Feature structure reference
247+
try:
248+
if is_annotation(value):
249+
return ("FS", value.type.name, value.begin, value.end)
234250
else:
235-
hash_ = _feature_value_hash(feature_value, hash_)
236-
return hash_
237-
238-
239-
def _feature_value_hash(feature_value: any, hash_: int):
240-
# Note we do not recurse further into arrays here because that could lead to endless loops!
241-
if type(feature_value) in (int, float, bool, str):
242-
return hash_ + hash(feature_value)
243-
else:
244-
# If we get here, it is a feature structure reference... we cannot really recursively
245-
# go into it to calculate a recursive hash... so we just check if the value is non-null
246-
return hash_ * (-1 if feature_value is None else 1)
251+
return ("FS", value.type.name)
252+
except Exception:
253+
# Fallback: string representation
254+
return ("FS", getattr(getattr(value, "type", None), "name", str(value)))

cassis/xmi.py

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -619,13 +619,23 @@ def _serialize_feature_structure(self, cas: Cas, root: etree.Element, fs: Featur
619619
continue
620620

621621
# Map back from offsets in Unicode codepoints to UIMA UTF-16 based offsets
622-
if (
623-
ts.is_instance_of(fs.type.name, TYPE_NAME_ANNOTATION)
624-
and feature_name == FEATURE_BASE_NAME_BEGIN
625-
or feature_name == FEATURE_BASE_NAME_END
622+
# Ensure we only convert begin/end for annotation instances. Parentheses are
623+
# required because `and` has higher precedence than `or` and we must not
624+
# attempt conversion for the END feature on non-annotations.
625+
if ts.is_instance_of(fs.type.name, TYPE_NAME_ANNOTATION) and (
626+
feature_name == FEATURE_BASE_NAME_BEGIN or feature_name == FEATURE_BASE_NAME_END
626627
):
627-
sofa: Sofa = fs.sofa
628-
value = sofa._offset_converter.python_to_external(value)
628+
# Be defensive: only perform offset conversion if the sofa and its
629+
# offset converter have been initialized. In some workflows (e.g. a
630+
# freshly constructed CAS without sofa strings) the converter may
631+
# not exist yet and conversion is not possible.
632+
sofa = getattr(fs, "sofa", None)
633+
if sofa is not None and getattr(sofa, "_offset_converter", None) is not None:
634+
value = sofa._offset_converter.python_to_external(value)
635+
636+
# If the offset is the default 0, still emit it. We do not track
637+
# original attribute presence; test fixtures should reflect the
638+
# desired serialized form.
629639

630640
if ts.is_instance_of(feature.rangeType, TYPE_NAME_STRING_ARRAY) and not feature.multipleReferencesAllowed:
631641
if value.elements is not None: # Compare to none as not to skip if elements is empty!

0 commit comments

Comments
 (0)