From 5f0437d6e58315a00fb8074896d8d253f10e2c90 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 8 Jun 2026 16:30:40 +0100 Subject: [PATCH 01/41] Move some basic typing things to its own module --- medcat-v2/medcat/components/base.py | 61 ++++++++++++++++++++++++++++ medcat-v2/medcat/components/types.py | 56 +------------------------ 2 files changed, 63 insertions(+), 54 deletions(-) create mode 100644 medcat-v2/medcat/components/base.py diff --git a/medcat-v2/medcat/components/base.py b/medcat-v2/medcat/components/base.py new file mode 100644 index 000000000..5ee0282c9 --- /dev/null +++ b/medcat-v2/medcat/components/base.py @@ -0,0 +1,61 @@ +from typing import Protocol, runtime_checkable, Optional +from typing_extensions import Self +from enum import Enum, auto + +from medcat.tokenizing.tokens import MutableDocument +from medcat.tokenizing.tokenizers import BaseTokenizer +from medcat.cdb import CDB +from medcat.vocab import Vocab +from medcat.config.config import ComponentConfig + + +@runtime_checkable +class BaseComponent(Protocol): + + @property + def full_name(self) -> Optional[str]: + """Name with the component type (e.g ner, linking, meta).""" + pass + + @property + def name(self) -> str: + """The name of the component.""" + pass + + def is_core(self) -> bool: + """Whether the component is a core component or not. + + Returns: + bool: Whether this is a core component. + """ + pass + + def __call__(self, doc: MutableDocument) -> MutableDocument: + pass + + @classmethod + def create_new_component( + cls, cnf: ComponentConfig, tokenizer: BaseTokenizer, + cdb: CDB, vocab: Vocab, model_load_path: Optional[str]) -> Self: + """Create a new component or load one off disk if load path presented. + + This may raise an exception if the wrong type of config is provided. + + Args: + cnf (ComponentConfig): The config relevant to this components. + tokenizer (BaseTokenizer): The base tokenizer. + cdb (CDB): The CDB. + vocab (Vocab): The Vocab. + model_load_path (Optional[str]): Model load path (if present). + + Returns: + Self: The new components. + """ + pass + + +class CoreComponentType(Enum): + tagging = auto() + token_normalizing = auto() + ner = auto() + linking = auto() diff --git a/medcat-v2/medcat/components/types.py b/medcat-v2/medcat/components/types.py index fe26bb1e0..939bafff9 100644 --- a/medcat-v2/medcat/components/types.py +++ b/medcat-v2/medcat/components/types.py @@ -1,7 +1,6 @@ from typing import Optional, Protocol, Callable, runtime_checkable, Union from typing import Literal -from typing_extensions import Self -from enum import Enum, auto +from enum import Enum from abc import ABC, abstractmethod from medcat.utils.registry import Registry, MedCATRegistryException @@ -10,58 +9,7 @@ from medcat.cdb import CDB from medcat.vocab import Vocab from medcat.config.config import ComponentConfig - - -class CoreComponentType(Enum): - tagging = auto() - token_normalizing = auto() - ner = auto() - linking = auto() - - -@runtime_checkable -class BaseComponent(Protocol): - - @property - def full_name(self) -> Optional[str]: - """Name with the component type (e.g ner, linking, meta).""" - pass - - @property - def name(self) -> str: - """The name of the component.""" - pass - - def is_core(self) -> bool: - """Whether the component is a core component or not. - - Returns: - bool: Whether this is a core component. - """ - pass - - def __call__(self, doc: MutableDocument) -> MutableDocument: - pass - - @classmethod - def create_new_component( - cls, cnf: ComponentConfig, tokenizer: BaseTokenizer, - cdb: CDB, vocab: Vocab, model_load_path: Optional[str]) -> Self: - """Create a new component or load one off disk if load path presented. - - This may raise an exception if the wrong type of config is provided. - - Args: - cnf (ComponentConfig): The config relevant to this components. - tokenizer (BaseTokenizer): The base tokenizer. - cdb (CDB): The CDB. - vocab (Vocab): The Vocab. - model_load_path (Optional[str]): Model load path (if present). - - Returns: - Self: The new components. - """ - pass +from medcat.components.base import BaseComponent, CoreComponentType @runtime_checkable From faa9fb02a9689f009c1bc618614f7fdd542b80f8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 9 Jun 2026 11:14:20 +0100 Subject: [PATCH 02/41] Add initial contracting bits --- medcat-v2/medcat/components/base.py | 44 ++++++- medcat-v2/medcat/components/contracting.py | 134 +++++++++++++++++++++ 2 files changed, 173 insertions(+), 5 deletions(-) create mode 100644 medcat-v2/medcat/components/contracting.py diff --git a/medcat-v2/medcat/components/base.py b/medcat-v2/medcat/components/base.py index 5ee0282c9..ff80e97f2 100644 --- a/medcat-v2/medcat/components/base.py +++ b/medcat-v2/medcat/components/base.py @@ -1,12 +1,13 @@ from typing import Protocol, runtime_checkable, Optional from typing_extensions import Self -from enum import Enum, auto +from enum import Enum from medcat.tokenizing.tokens import MutableDocument from medcat.tokenizing.tokenizers import BaseTokenizer from medcat.cdb import CDB from medcat.vocab import Vocab from medcat.config.config import ComponentConfig +from medcat.components.contracting import ComponentContract, CollectionContract @runtime_checkable @@ -55,7 +56,40 @@ def create_new_component( class CoreComponentType(Enum): - tagging = auto() - token_normalizing = auto() - ner = auto() - linking = auto() + tagging = ComponentContract( + needs=frozenset(), + must_provide=frozenset(), + # doesn't write for every token + may_provide=frozenset({'token.is_punctuation', 'token.to_skip'}), + collection_contracts=frozenset(), + ) + token_normalizing = ComponentContract( + needs=frozenset(), + # should write for every token + must_provide=frozenset({'token.norm'}), + may_provide=frozenset(), + collection_contracts=frozenset(), + ) + ner = ComponentContract( + needs=frozenset({'token.to_skip'}), + must_provide=frozenset({'doc.ner_ents'}), # the list must exist + may_provide=frozenset(), + collection_contracts=frozenset({ + CollectionContract( + field='doc.ner_ents', + must_provide=frozenset({'detected_name'}), + ) + }), + ) + linking = ComponentContract( + needs=frozenset({'doc.ner_ents'}), + # must write, but may be empty list + must_provide=frozenset({'doc.linked_ents'}), + may_provide=frozenset({}), + collection_contracts=frozenset({ + CollectionContract( + field='doc.linked_ents', + must_provide=frozenset({'cui', 'context_similarity'}), + ), + }), + ) diff --git a/medcat-v2/medcat/components/contracting.py b/medcat-v2/medcat/components/contracting.py new file mode 100644 index 000000000..4fc0fabe3 --- /dev/null +++ b/medcat-v2/medcat/components/contracting.py @@ -0,0 +1,134 @@ +from enum import Enum, auto +from typing import Any, Iterator, Callable +from pydantic import BaseModel +from contextlib import contextmanager + +from medcat.tokenizing.tokens import MutableDocument +from medcat.components.base import BaseComponent + + +class CollectionContract(BaseModel, frozen=True): + """Contract for a collection field — what each item in the collection provides.""" + field: str # e.g. 'ner_ents' + must_provide: frozenset[str] # fields every item must have + may_provide: frozenset[str] = frozenset() + + +class ComponentContract(BaseModel, frozen=True): + needs: frozenset[str] + must_provide: frozenset[str] + may_provide: frozenset[str] = frozenset() + collection_contracts: frozenset[CollectionContract] = frozenset() + + +class ContractViolation(Exception): + pass + + +def iter_relevant_parts(doc: MutableDocument, path: str) -> Iterator[Any]: + if path.startswith("doc."): + yield doc + return + if path.startswith("token."): + yield from doc[:] + else: + raise ValueError(f"Unknown path: {path}") + + +class AccessType(Enum): + READ = auto() + WRITE = auto() + + +@contextmanager +def wrap_relevant_parts( + doc: MutableDocument, + path: str, + access_type: AccessType = AccessType.READ, +): + member_name = path.split(".", 1)[1] + out_list: list[list[Any]] = [] + for part in iter_relevant_parts(doc, path): + # TODO: wrap such that I can tell that it has been accessed + member = getattr(part, member_name) + feedback: list[Any] = [] + setattr(part, WrappedMember(member, feedback, access_type=access_type)) + out_list.append(feedback) + yield feedback + + +def verify_part( + text: str, + doc_getter: Callable[[str], MutableDocument], + component: BaseComponent, + paths: list[str], + access_type: AccessType, + raise_on_violation: bool = True, +): + violations: list[str] = [] + for path in paths: + doc = doc_getter(text) + with wrap_relevant_parts(doc, path) as feedback: + doc = component(doc) + # verify each one accessed + accessed = sum(fb for fb in feedback) + total = len(feedback) + if accessed != total: + violations.append( + f"Component {component.full_name} does not {access_type.name}" + f"{path} ({accessed} / {total} accessed)") + if raise_on_violation: + raise ContractViolation("\n".join(violations)) + return violations + + +def verify_needs( + text: str, + doc_getter: Callable[[str], MutableDocument], + component: BaseComponent, + contract: ComponentContract, + raise_on_violation: bool = True, +) -> list[str]: + return verify_part( + text, doc_getter, component, contract.needs, + AccessType.READ, raise_on_violation=raise_on_violation, + ) + + +def verify_must_provide( + text: str, + doc_getter: Callable[[str], MutableDocument], + component: BaseComponent, + contract: ComponentContract, + raise_on_violation: bool = True, +) -> list[str]: + return verify_part( + text, doc_getter, component, contract.must_provide, + AccessType.WRITE, raise_on_violation=raise_on_violation, + ) + + +def verify_contract( + text: str, + doc_getter: Callable[[str], MutableDocument], + component: BaseComponent, + contract: ComponentContract, + raise_on_violation: bool = True, +) -> list[str]: + """ + Verify a ComponentContract against a document before/after a component ran. + Returns a list of violation messages. Raises ContractViolation if raise_on_violation. + """ + # verify needs are met + violations = verify_needs( + text, doc_getter, component, contract, raise_on_violation=False + ) + # verify mandatory returns are done + violations += verify_must_provide( + text, doc_getter, component, contract, raise_on_violation=False + ) + + if violations and raise_on_violation: + raise ContractViolation("\n".join(violations)) + + return violations From 8eb194496ca9c2bbd471fd8011d7c90f33207508 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 9 Jun 2026 15:39:49 +0100 Subject: [PATCH 03/41] Remove unused import --- medcat-v2/medcat/components/types.py | 1 - 1 file changed, 1 deletion(-) diff --git a/medcat-v2/medcat/components/types.py b/medcat-v2/medcat/components/types.py index 939bafff9..76e1ede74 100644 --- a/medcat-v2/medcat/components/types.py +++ b/medcat-v2/medcat/components/types.py @@ -1,6 +1,5 @@ from typing import Optional, Protocol, Callable, runtime_checkable, Union from typing import Literal -from enum import Enum from abc import ABC, abstractmethod from medcat.utils.registry import Registry, MedCATRegistryException From 3072f9eec2c058d9b9a52ed7f6a756903f95db92 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 9 Jun 2026 15:40:26 +0100 Subject: [PATCH 04/41] Finalise contracting module --- medcat-v2/medcat/components/contracting.py | 83 +++++++++++++++++++--- 1 file changed, 72 insertions(+), 11 deletions(-) diff --git a/medcat-v2/medcat/components/contracting.py b/medcat-v2/medcat/components/contracting.py index 4fc0fabe3..95a7d8255 100644 --- a/medcat-v2/medcat/components/contracting.py +++ b/medcat-v2/medcat/components/contracting.py @@ -1,7 +1,8 @@ from enum import Enum, auto from typing import Any, Iterator, Callable from pydantic import BaseModel -from contextlib import contextmanager +from contextlib import contextmanager, ExitStack +from copy import deepcopy from medcat.tokenizing.tokens import MutableDocument from medcat.components.base import BaseComponent @@ -40,6 +41,61 @@ class AccessType(Enum): WRITE = auto() +class WrappedMember: + + def __init__( + self, + part: Any, + member_name: str, + feedback: list[Any], + access_type: AccessType, + ) -> None: + self.part = part + self.member_name = member_name + self.feedback = feedback + self.access_type = access_type + self._oirg_class = type(self.part) + self._install() + + def _install(self): + original_cls = type(self.part) + spy = self # capture for closure + + if self.access_type == AccessType.READ: + + class SpySubclass(original_cls): + def __getattribute__(self, name): + val = super().__getattribute__(name) + if name == spy.member_name: + # save copy + spy.feedback.append(deepcopy(val)) + return val + + elif self.access_type == AccessType.WRITE: + + class SpySubclass(original_cls): + def __setattr__(self, name, value): + if name in spy.member_name: + try: + old = super().__getattribute__(name) + except AttributeError: + old = AttributeError # sentinel: didn't exist yet + # saving copies of state + spy.feedback.append((deepcopy(old), deepcopy(value))) + super().__setattr__(name, value) + + + SpySubclass.__name__ = f"Spy({original_cls.__name__})" + SpySubclass.__qualname__ = SpySubclass.__name__ + self.part.__class__ = SpySubclass + + def __enter__(self): + return self + + def __exit__(self, *_): + self.part.__class__ = self._oirg_class + + @contextmanager def wrap_relevant_parts( doc: MutableDocument, @@ -48,13 +104,16 @@ def wrap_relevant_parts( ): member_name = path.split(".", 1)[1] out_list: list[list[Any]] = [] - for part in iter_relevant_parts(doc, path): - # TODO: wrap such that I can tell that it has been accessed - member = getattr(part, member_name) - feedback: list[Any] = [] - setattr(part, WrappedMember(member, feedback, access_type=access_type)) - out_list.append(feedback) - yield feedback + with ExitStack() as exit_stack: + for part in iter_relevant_parts(doc, path): + feedback: list[Any] = [] + exit_stack.enter_context( + WrappedMember( + part, member_name, + feedback, access_type=access_type) + ) + out_list.append(feedback) + yield feedback def verify_part( @@ -90,7 +149,7 @@ def verify_needs( raise_on_violation: bool = True, ) -> list[str]: return verify_part( - text, doc_getter, component, contract.needs, + text, doc_getter, component, list(contract.needs), AccessType.READ, raise_on_violation=raise_on_violation, ) @@ -103,7 +162,7 @@ def verify_must_provide( raise_on_violation: bool = True, ) -> list[str]: return verify_part( - text, doc_getter, component, contract.must_provide, + text, doc_getter, component, list(contract.must_provide), AccessType.WRITE, raise_on_violation=raise_on_violation, ) @@ -117,7 +176,9 @@ def verify_contract( ) -> list[str]: """ Verify a ComponentContract against a document before/after a component ran. - Returns a list of violation messages. Raises ContractViolation if raise_on_violation. + Returns a list of violation messages. + + Raises ContractViolation if violations found and raise_on_violation. """ # verify needs are met violations = verify_needs( From 7226bf41aba6872c3924bfadfcded89b58974d50 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 9 Jun 2026 15:43:51 +0100 Subject: [PATCH 05/41] Add debug logging regarding violations as they come --- medcat-v2/medcat/components/contracting.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/medcat-v2/medcat/components/contracting.py b/medcat-v2/medcat/components/contracting.py index 95a7d8255..81b8477af 100644 --- a/medcat-v2/medcat/components/contracting.py +++ b/medcat-v2/medcat/components/contracting.py @@ -3,11 +3,14 @@ from pydantic import BaseModel from contextlib import contextmanager, ExitStack from copy import deepcopy +from logging import Logger from medcat.tokenizing.tokens import MutableDocument from medcat.components.base import BaseComponent +logger = Logger(__name__) + class CollectionContract(BaseModel, frozen=True): """Contract for a collection field — what each item in the collection provides.""" field: str # e.g. 'ner_ents' @@ -136,6 +139,12 @@ def verify_part( violations.append( f"Component {component.full_name} does not {access_type.name}" f"{path} ({accessed} / {total} accessed)") + logger.debug( + "Found a virolation in component '%s' for %s at '%s': " + "(%d / %d) accessed with feedback %s", + component.full_name, access_type.name, + path, accessed, total, feedback, + ) if raise_on_violation: raise ContractViolation("\n".join(violations)) return violations From d2d72589bea442aecc53a9531f7b8221bd9bb051 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 9 Jun 2026 15:56:25 +0100 Subject: [PATCH 06/41] Shuffle classes around a little to avoid circular imports --- medcat-v2/medcat/components/base.py | 16 +++++++++++++++- medcat-v2/medcat/components/contracting.py | 16 +--------------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/medcat-v2/medcat/components/base.py b/medcat-v2/medcat/components/base.py index ff80e97f2..2d64b945c 100644 --- a/medcat-v2/medcat/components/base.py +++ b/medcat-v2/medcat/components/base.py @@ -2,12 +2,13 @@ from typing_extensions import Self from enum import Enum +from pydantic import BaseModel + from medcat.tokenizing.tokens import MutableDocument from medcat.tokenizing.tokenizers import BaseTokenizer from medcat.cdb import CDB from medcat.vocab import Vocab from medcat.config.config import ComponentConfig -from medcat.components.contracting import ComponentContract, CollectionContract @runtime_checkable @@ -54,6 +55,19 @@ def create_new_component( """ pass +class CollectionContract(BaseModel, frozen=True): + """Contract for a collection field — what each item in the collection provides.""" + field: str # e.g. 'ner_ents' + must_provide: frozenset[str] # fields every item must have + may_provide: frozenset[str] = frozenset() + + +class ComponentContract(BaseModel, frozen=True): + needs: frozenset[str] + must_provide: frozenset[str] + may_provide: frozenset[str] = frozenset() + collection_contracts: frozenset[CollectionContract] = frozenset() + class CoreComponentType(Enum): tagging = ComponentContract( diff --git a/medcat-v2/medcat/components/contracting.py b/medcat-v2/medcat/components/contracting.py index 81b8477af..7f563ec68 100644 --- a/medcat-v2/medcat/components/contracting.py +++ b/medcat-v2/medcat/components/contracting.py @@ -1,29 +1,15 @@ from enum import Enum, auto from typing import Any, Iterator, Callable -from pydantic import BaseModel from contextlib import contextmanager, ExitStack from copy import deepcopy from logging import Logger from medcat.tokenizing.tokens import MutableDocument -from medcat.components.base import BaseComponent +from medcat.components.base import BaseComponent, ComponentContract logger = Logger(__name__) -class CollectionContract(BaseModel, frozen=True): - """Contract for a collection field — what each item in the collection provides.""" - field: str # e.g. 'ner_ents' - must_provide: frozenset[str] # fields every item must have - may_provide: frozenset[str] = frozenset() - - -class ComponentContract(BaseModel, frozen=True): - needs: frozenset[str] - must_provide: frozenset[str] - may_provide: frozenset[str] = frozenset() - collection_contracts: frozenset[CollectionContract] = frozenset() - class ContractViolation(Exception): pass From 99b80132b6e07ab8444dd144d44983dfe110f41b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 9 Jun 2026 16:02:09 +0100 Subject: [PATCH 07/41] Fix minor issue in contracting module --- medcat-v2/medcat/components/contracting.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/medcat-v2/medcat/components/contracting.py b/medcat-v2/medcat/components/contracting.py index 7f563ec68..f06cbab63 100644 --- a/medcat-v2/medcat/components/contracting.py +++ b/medcat-v2/medcat/components/contracting.py @@ -119,7 +119,7 @@ def verify_part( with wrap_relevant_parts(doc, path) as feedback: doc = component(doc) # verify each one accessed - accessed = sum(fb for fb in feedback) + accessed = sum(bool(fb) for fb in feedback) total = len(feedback) if accessed != total: violations.append( From 69e52a9d0362c61a66ad33a9d02835f83417ac5c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 9 Jun 2026 16:06:33 +0100 Subject: [PATCH 08/41] Fix minor issue in violation formatting --- medcat-v2/medcat/components/contracting.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/medcat-v2/medcat/components/contracting.py b/medcat-v2/medcat/components/contracting.py index f06cbab63..29d1cd146 100644 --- a/medcat-v2/medcat/components/contracting.py +++ b/medcat-v2/medcat/components/contracting.py @@ -123,7 +123,7 @@ def verify_part( total = len(feedback) if accessed != total: violations.append( - f"Component {component.full_name} does not {access_type.name}" + f"Component {component.full_name} does not {access_type.name} " f"{path} ({accessed} / {total} accessed)") logger.debug( "Found a virolation in component '%s' for %s at '%s': " From 7a2f2a874b42a10e8c1d89eaade5da9de427d090 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 9 Jun 2026 16:34:36 +0100 Subject: [PATCH 09/41] Allow running pipe until specific component --- medcat-v2/medcat/pipeline/pipeline.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/medcat-v2/medcat/pipeline/pipeline.py b/medcat-v2/medcat/pipeline/pipeline.py index 70b9ba11f..0d7241562 100644 --- a/medcat-v2/medcat/pipeline/pipeline.py +++ b/medcat-v2/medcat/pipeline/pipeline.py @@ -324,8 +324,33 @@ def get_doc(self, text: str) -> MutableDocument: Returns: MutableDocument: The resulting document. """ + return self.pipe_until(text, None) + + def pipe_until( + self, + text: str, + comp_type: Optional[CoreComponentType] + ) -> MutableDocument: + """Run the pipe until the specific component (excluded). + + If `comp_type == None` then the entire pipe is run. + Otherwise the pipe is stopped before the specificied component is run. + + Args: + text (str): The text to run over. + comp_type (Optional[CoreComponentType]): The last component to run or None. + + Returns: + MutableDocument: The processed document. + """ doc = self._tokenizer(text) for comp in self._components: + if comp_type and comp.get_type() == comp_type: + logger.info( + "Finishing pipe before %s (%s) as requested", + comp.get_type().name, comp.full_name + ) + return doc logger.info("Running component %s for %d of text (%s)", comp.full_name, len(text), id(text)) doc = comp(doc) @@ -333,6 +358,7 @@ def get_doc(self, text: str) -> MutableDocument: doc = addon(doc) return doc + def entity_from_tokens(self, tokens: list[MutableToken]) -> MutableEntity: """Get the entity from the list of tokens. From 9f285f67c1d72d7a925a229cf3d602091485bce4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 9 Jun 2026 16:43:07 +0100 Subject: [PATCH 10/41] Update copies of state in contracting --- medcat-v2/medcat/components/contracting.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/medcat-v2/medcat/components/contracting.py b/medcat-v2/medcat/components/contracting.py index 29d1cd146..20b5c2ed3 100644 --- a/medcat-v2/medcat/components/contracting.py +++ b/medcat-v2/medcat/components/contracting.py @@ -1,7 +1,6 @@ from enum import Enum, auto from typing import Any, Iterator, Callable from contextlib import contextmanager, ExitStack -from copy import deepcopy from logging import Logger from medcat.tokenizing.tokens import MutableDocument @@ -56,8 +55,8 @@ class SpySubclass(original_cls): def __getattribute__(self, name): val = super().__getattribute__(name) if name == spy.member_name: - # save copy - spy.feedback.append(deepcopy(val)) + # saving str copy of value + spy.feedback.append(str(val)) return val elif self.access_type == AccessType.WRITE: @@ -69,8 +68,8 @@ def __setattr__(self, name, value): old = super().__getattribute__(name) except AttributeError: old = AttributeError # sentinel: didn't exist yet - # saving copies of state - spy.feedback.append((deepcopy(old), deepcopy(value))) + # saving str copies of state + spy.feedback.append((str(old), str(value))) super().__setattr__(name, value) From 1458f95d95aef0b39a494732132b34d6ab693f5b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 18 Jun 2026 15:05:16 +0100 Subject: [PATCH 11/41] Add minimum feedback numbers --- medcat-v2/medcat/components/contracting.py | 27 ++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/medcat-v2/medcat/components/contracting.py b/medcat-v2/medcat/components/contracting.py index 20b5c2ed3..e4e132b20 100644 --- a/medcat-v2/medcat/components/contracting.py +++ b/medcat-v2/medcat/components/contracting.py @@ -101,7 +101,7 @@ def wrap_relevant_parts( feedback, access_type=access_type) ) out_list.append(feedback) - yield feedback + yield out_list def verify_part( @@ -111,13 +111,14 @@ def verify_part( paths: list[str], access_type: AccessType, raise_on_violation: bool = True, + min_feedbacks: int = 0, ): violations: list[str] = [] for path in paths: doc = doc_getter(text) with wrap_relevant_parts(doc, path) as feedback: doc = component(doc) - # verify each one accessed + # verify each one access accessed = sum(bool(fb) for fb in feedback) total = len(feedback) if accessed != total: @@ -130,6 +131,16 @@ def verify_part( component.full_name, access_type.name, path, accessed, total, feedback, ) + elif total < min_feedbacks: + violations.append( + f"Component {component.full_name} did not {access_type.name} " + f"{path} enough ({total} with minimum {min_feedbacks})") + logger.debug( + "Component '%s' did not %s " + "'%s' enough (%d with minimum %d)", + component.full_name, access_type.name, + path, total, min_feedbacks, + ) if raise_on_violation: raise ContractViolation("\n".join(violations)) return violations @@ -141,10 +152,12 @@ def verify_needs( component: BaseComponent, contract: ComponentContract, raise_on_violation: bool = True, + min_feedbacks: int = 0, ) -> list[str]: return verify_part( text, doc_getter, component, list(contract.needs), AccessType.READ, raise_on_violation=raise_on_violation, + min_feedbacks=min_feedbacks, ) @@ -154,10 +167,12 @@ def verify_must_provide( component: BaseComponent, contract: ComponentContract, raise_on_violation: bool = True, + min_feedbacks: int = 0, ) -> list[str]: return verify_part( text, doc_getter, component, list(contract.must_provide), AccessType.WRITE, raise_on_violation=raise_on_violation, + min_feedbacks=min_feedbacks, ) @@ -167,6 +182,8 @@ def verify_contract( component: BaseComponent, contract: ComponentContract, raise_on_violation: bool = True, + min_feedbacks_need: int = 0, + min_feedbacks_provide: int = 0, ) -> list[str]: """ Verify a ComponentContract against a document before/after a component ran. @@ -176,11 +193,13 @@ def verify_contract( """ # verify needs are met violations = verify_needs( - text, doc_getter, component, contract, raise_on_violation=False + text, doc_getter, component, contract, raise_on_violation=False, + min_feedbacks=min_feedbacks_need, ) # verify mandatory returns are done violations += verify_must_provide( - text, doc_getter, component, contract, raise_on_violation=False + text, doc_getter, component, contract, raise_on_violation=False, + min_feedbacks=min_feedbacks_provide, ) if violations and raise_on_violation: From 3925020a181c8b34f99806a4ff78a5f67878e8e9 Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Wed, 24 Jun 2026 09:19:12 +0100 Subject: [PATCH 12/41] Add initial contracting tests (WIP) --- .../tests/components/test_contracting.py | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 medcat-v2/tests/components/test_contracting.py diff --git a/medcat-v2/tests/components/test_contracting.py b/medcat-v2/tests/components/test_contracting.py new file mode 100644 index 000000000..ed176b8ef --- /dev/null +++ b/medcat-v2/tests/components/test_contracting.py @@ -0,0 +1,57 @@ +from unittest import TestCase +from typing import Callable + +from medcat.cat import CAT +from medcat.components import contracting +from medcat.components.base import CoreComponentType +from medcat.components.ner.vocab_based_ner import NER +from medcat.components.linking.context_based_linker import Linker +from medcat.tokenizing.tokens import MutableDocument +from tests import UNPACKED_EXAMPLE_MODEL_PACK_PATH + + + +class TestContractingNer(TestCase): + comp_type = CoreComponentType.ner + comp_cls = NER + args: Callable[[], list] = list + min_feedbacks_need = 0 + min_feedbacks_provide = 1 + text = "John had been diagnosed with acute Kidney Failure the week before." + + @classmethod + def component_prep(cls, text: str) -> MutableDocument: + return cls._model.pipe.pipe_until(text, cls.comp_type) + + @classmethod + def setUpClass(cls) -> None: + cls._model = CAT.load_model_pack(UNPACKED_EXAMPLE_MODEL_PACK_PATH) + cls.tokenizer = cls._model.pipe.tokenizer + cls.cdb = cls._model.cdb + if not cls.args(): + cls.comp = cls.comp_cls(cls.tokenizer, cls.cdb) + else: + cls.comp = cls.comp_cls(*cls.args()) + return super().setUpClass() + + def test_contract_held(self): + violations = contracting.verify_contract( + self.text, self.component_prep, self.comp, self.comp_type.value, + raise_on_violation=False, + min_feedbacks_need=self.min_feedbacks_need, + min_feedbacks_provide=self.min_feedbacks_provide, + ) + self.assertFalse(violations, "Expected not violations") + # self.assertTrue(self.comp(self.component_prep(self.text))) + + +class TestContractingLinker(TestContractingNer): + comp_type = CoreComponentType.linking + comp_cls = Linker + min_feedbacks_need = 1 + + @classmethod + def setUpClass(cls) -> None: + cls.args = lambda: [ + cls.cdb, cls._model.vocab, cls._model.config] + return super().setUpClass() From f2ac5303884187985aa7f82eecd0d2a6e298650c Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Thu, 16 Jul 2026 15:55:12 +0100 Subject: [PATCH 13/41] Update contracting to fix token spying --- medcat-v2/medcat/components/contracting.py | 82 +++++++++++++++++++++- 1 file changed, 80 insertions(+), 2 deletions(-) diff --git a/medcat-v2/medcat/components/contracting.py b/medcat-v2/medcat/components/contracting.py index e4e132b20..7631a4903 100644 --- a/medcat-v2/medcat/components/contracting.py +++ b/medcat-v2/medcat/components/contracting.py @@ -1,6 +1,7 @@ from enum import Enum, auto -from typing import Any, Iterator, Callable +from typing import Any, Iterator, Callable, Type from contextlib import contextmanager, ExitStack +from collections import defaultdict from logging import Logger from medcat.tokenizing.tokens import MutableDocument @@ -10,6 +11,9 @@ logger = Logger(__name__) +_SENTINEL = object() + + class ContractViolation(Exception): pass @@ -72,7 +76,6 @@ def __setattr__(self, name, value): spy.feedback.append((str(old), str(value))) super().__setattr__(name, value) - SpySubclass.__name__ = f"Spy({original_cls.__name__})" SpySubclass.__qualname__ = SpySubclass.__name__ self.part.__class__ = SpySubclass @@ -84,11 +87,86 @@ def __exit__(self, *_): self.part.__class__ = self._oirg_class +@contextmanager +def spy_token_class( + token_cls: Type, + watched_attr: str, + access_type: AccessType, +): + prev_getattr = token_cls.__dict__.get('__getattribute__', _SENTINEL) + prev_setattr = token_cls.__dict__.get('__setattr__', _SENTINEL) + + per_instance_spied: dict[Any, list[Any]] = defaultdict(list) + + def __getattribute__(self, name: str) -> Any: + val = object.__getattribute__(self, name) + if name == watched_attr: + per_instance_spied[self].append(str(val)) + return val + + def __setattr__(self, name: str, value: Any): + old = object.__getattribute__(self, name) + object.__setattr__(self, name, value) + if name == watched_attr: + per_instance_spied[self].append((str(old), str(value))) + + if access_type == AccessType.READ: + token_cls.__getattribute__ = __getattribute__ + elif access_type == AccessType.WRITE: + token_cls.__setattr__ = __setattr__ + else: + raise ValueError(f"Unknown access type: {access_type}") + try: + yield per_instance_spied + finally: + if prev_getattr is _SENTINEL: + del token_cls.__getattribute__ + else: + token_cls.__getattribute__ = prev_getattr + + if prev_setattr is _SENTINEL: + token_cls.__setattr__ = object.__setattr__ + else: + token_cls.__setattr__ = prev_setattr + + @contextmanager def wrap_relevant_parts( doc: MutableDocument, path: str, access_type: AccessType = AccessType.READ, +): + if path.startswith("doc."): + with wrap_relevant_persistant_parts( + doc, path, access_type + ) as feedbacks: + yield feedbacks + elif path.startswith("token."): + with wrap_relevant_token_cls( + doc, path, access_type + ) as feedbacks: + yield feedbacks + + +@contextmanager +def wrap_relevant_token_cls( + doc: MutableDocument, + path: str, + access_type: AccessType = AccessType.READ, +): + _, attr_name = path.split(".", 1) + tkn_cls = type(next(iter(doc))) + with spy_token_class( + tkn_cls, attr_name, access_type + ) as per_instance_spied: + yield list(per_instance_spied.values()) + + +@contextmanager +def wrap_relevant_persistant_parts( + doc: MutableDocument, + path: str, + access_type: AccessType = AccessType.READ, ): member_name = path.split(".", 1)[1] out_list: list[list[Any]] = [] From 251e5f6cf127338771ef97ce7fc010994ea76529 Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Thu, 16 Jul 2026 16:02:55 +0100 Subject: [PATCH 14/41] Move some contracting stuff to the a separate utility module --- medcat-v2/medcat/components/contracting.py | 178 +---------------- .../medcat/components/contracting_utils.py | 180 ++++++++++++++++++ 2 files changed, 183 insertions(+), 175 deletions(-) create mode 100644 medcat-v2/medcat/components/contracting_utils.py diff --git a/medcat-v2/medcat/components/contracting.py b/medcat-v2/medcat/components/contracting.py index 7631a4903..2074f6756 100644 --- a/medcat-v2/medcat/components/contracting.py +++ b/medcat-v2/medcat/components/contracting.py @@ -1,187 +1,15 @@ -from enum import Enum, auto -from typing import Any, Iterator, Callable, Type -from contextlib import contextmanager, ExitStack -from collections import defaultdict +from typing import Callable from logging import Logger from medcat.tokenizing.tokens import MutableDocument from medcat.components.base import BaseComponent, ComponentContract +from medcat.components.contracting_utils import ( + AccessType, wrap_relevant_parts, ContractViolation) logger = Logger(__name__) -_SENTINEL = object() - - -class ContractViolation(Exception): - pass - - -def iter_relevant_parts(doc: MutableDocument, path: str) -> Iterator[Any]: - if path.startswith("doc."): - yield doc - return - if path.startswith("token."): - yield from doc[:] - else: - raise ValueError(f"Unknown path: {path}") - - -class AccessType(Enum): - READ = auto() - WRITE = auto() - - -class WrappedMember: - - def __init__( - self, - part: Any, - member_name: str, - feedback: list[Any], - access_type: AccessType, - ) -> None: - self.part = part - self.member_name = member_name - self.feedback = feedback - self.access_type = access_type - self._oirg_class = type(self.part) - self._install() - - def _install(self): - original_cls = type(self.part) - spy = self # capture for closure - - if self.access_type == AccessType.READ: - - class SpySubclass(original_cls): - def __getattribute__(self, name): - val = super().__getattribute__(name) - if name == spy.member_name: - # saving str copy of value - spy.feedback.append(str(val)) - return val - - elif self.access_type == AccessType.WRITE: - - class SpySubclass(original_cls): - def __setattr__(self, name, value): - if name in spy.member_name: - try: - old = super().__getattribute__(name) - except AttributeError: - old = AttributeError # sentinel: didn't exist yet - # saving str copies of state - spy.feedback.append((str(old), str(value))) - super().__setattr__(name, value) - - SpySubclass.__name__ = f"Spy({original_cls.__name__})" - SpySubclass.__qualname__ = SpySubclass.__name__ - self.part.__class__ = SpySubclass - - def __enter__(self): - return self - - def __exit__(self, *_): - self.part.__class__ = self._oirg_class - - -@contextmanager -def spy_token_class( - token_cls: Type, - watched_attr: str, - access_type: AccessType, -): - prev_getattr = token_cls.__dict__.get('__getattribute__', _SENTINEL) - prev_setattr = token_cls.__dict__.get('__setattr__', _SENTINEL) - - per_instance_spied: dict[Any, list[Any]] = defaultdict(list) - - def __getattribute__(self, name: str) -> Any: - val = object.__getattribute__(self, name) - if name == watched_attr: - per_instance_spied[self].append(str(val)) - return val - - def __setattr__(self, name: str, value: Any): - old = object.__getattribute__(self, name) - object.__setattr__(self, name, value) - if name == watched_attr: - per_instance_spied[self].append((str(old), str(value))) - - if access_type == AccessType.READ: - token_cls.__getattribute__ = __getattribute__ - elif access_type == AccessType.WRITE: - token_cls.__setattr__ = __setattr__ - else: - raise ValueError(f"Unknown access type: {access_type}") - try: - yield per_instance_spied - finally: - if prev_getattr is _SENTINEL: - del token_cls.__getattribute__ - else: - token_cls.__getattribute__ = prev_getattr - - if prev_setattr is _SENTINEL: - token_cls.__setattr__ = object.__setattr__ - else: - token_cls.__setattr__ = prev_setattr - - -@contextmanager -def wrap_relevant_parts( - doc: MutableDocument, - path: str, - access_type: AccessType = AccessType.READ, -): - if path.startswith("doc."): - with wrap_relevant_persistant_parts( - doc, path, access_type - ) as feedbacks: - yield feedbacks - elif path.startswith("token."): - with wrap_relevant_token_cls( - doc, path, access_type - ) as feedbacks: - yield feedbacks - - -@contextmanager -def wrap_relevant_token_cls( - doc: MutableDocument, - path: str, - access_type: AccessType = AccessType.READ, -): - _, attr_name = path.split(".", 1) - tkn_cls = type(next(iter(doc))) - with spy_token_class( - tkn_cls, attr_name, access_type - ) as per_instance_spied: - yield list(per_instance_spied.values()) - - -@contextmanager -def wrap_relevant_persistant_parts( - doc: MutableDocument, - path: str, - access_type: AccessType = AccessType.READ, -): - member_name = path.split(".", 1)[1] - out_list: list[list[Any]] = [] - with ExitStack() as exit_stack: - for part in iter_relevant_parts(doc, path): - feedback: list[Any] = [] - exit_stack.enter_context( - WrappedMember( - part, member_name, - feedback, access_type=access_type) - ) - out_list.append(feedback) - yield out_list - - def verify_part( text: str, doc_getter: Callable[[str], MutableDocument], diff --git a/medcat-v2/medcat/components/contracting_utils.py b/medcat-v2/medcat/components/contracting_utils.py new file mode 100644 index 000000000..b5366fe0a --- /dev/null +++ b/medcat-v2/medcat/components/contracting_utils.py @@ -0,0 +1,180 @@ +from typing import Any, Iterator, Type +from contextlib import contextmanager, ExitStack +from collections import defaultdict +from logging import Logger +from enum import Enum, auto + +from medcat.tokenizing.tokens import MutableDocument + + +logger = Logger(__name__) + +_SENTINEL = object() + + +class ContractViolation(Exception): + pass + + +class AccessType(Enum): + READ = auto() + WRITE = auto() + + +def iter_relevant_parts(doc: MutableDocument, path: str) -> Iterator[Any]: + if path.startswith("doc."): + yield doc + return + if path.startswith("token."): + yield from doc[:] + else: + raise ValueError(f"Unknown path: {path}") + + +class WrappedMember: + + def __init__( + self, + part: Any, + member_name: str, + feedback: list[Any], + access_type: AccessType, + ) -> None: + self.part = part + self.member_name = member_name + self.feedback = feedback + self.access_type = access_type + self._oirg_class = type(self.part) + self._install() + + def _install(self): + original_cls = type(self.part) + spy = self # capture for closure + + if self.access_type == AccessType.READ: + + class SpySubclass(original_cls): + def __getattribute__(self, name): + val = super().__getattribute__(name) + if name == spy.member_name: + # saving str copy of value + spy.feedback.append(str(val)) + return val + + elif self.access_type == AccessType.WRITE: + + class SpySubclass(original_cls): + def __setattr__(self, name, value): + if name in spy.member_name: + try: + old = super().__getattribute__(name) + except AttributeError: + old = AttributeError # sentinel: didn't exist yet + # saving str copies of state + spy.feedback.append((str(old), str(value))) + super().__setattr__(name, value) + + SpySubclass.__name__ = f"Spy({original_cls.__name__})" + SpySubclass.__qualname__ = SpySubclass.__name__ + self.part.__class__ = SpySubclass + + def __enter__(self): + return self + + def __exit__(self, *_): + self.part.__class__ = self._oirg_class + + +@contextmanager +def spy_token_class( + token_cls: Type, + watched_attr: str, + access_type: AccessType, +): + prev_getattr = token_cls.__dict__.get('__getattribute__', _SENTINEL) + prev_setattr = token_cls.__dict__.get('__setattr__', _SENTINEL) + + per_instance_spied: dict[Any, list[Any]] = defaultdict(list) + + def __getattribute__(self, name: str) -> Any: + val = object.__getattribute__(self, name) + if name == watched_attr: + per_instance_spied[self].append(str(val)) + return val + + def __setattr__(self, name: str, value: Any): + old = object.__getattribute__(self, name) + object.__setattr__(self, name, value) + if name == watched_attr: + per_instance_spied[self].append((str(old), str(value))) + + if access_type == AccessType.READ: + token_cls.__getattribute__ = __getattribute__ + elif access_type == AccessType.WRITE: + token_cls.__setattr__ = __setattr__ + else: + raise ValueError(f"Unknown access type: {access_type}") + try: + yield per_instance_spied + finally: + if prev_getattr is _SENTINEL: + del token_cls.__getattribute__ + else: + token_cls.__getattribute__ = prev_getattr + + if prev_setattr is _SENTINEL: + token_cls.__setattr__ = object.__setattr__ + else: + token_cls.__setattr__ = prev_setattr + + +@contextmanager +def wrap_relevant_parts( + doc: MutableDocument, + path: str, + access_type: AccessType = AccessType.READ, +): + if path.startswith("doc."): + with wrap_relevant_persistant_parts( + doc, path, access_type + ) as feedbacks: + yield feedbacks + elif path.startswith("token."): + with wrap_relevant_token_cls( + doc, path, access_type + ) as feedbacks: + yield feedbacks + + +@contextmanager +def wrap_relevant_token_cls( + doc: MutableDocument, + path: str, + access_type: AccessType = AccessType.READ, +): + _, attr_name = path.split(".", 1) + tkn_cls = type(next(iter(doc))) + with spy_token_class( + tkn_cls, attr_name, access_type + ) as per_instance_spied: + yield list(per_instance_spied.values()) + + +@contextmanager +def wrap_relevant_persistant_parts( + doc: MutableDocument, + path: str, + access_type: AccessType = AccessType.READ, +): + member_name = path.split(".", 1)[1] + out_list: list[list[Any]] = [] + with ExitStack() as exit_stack: + for part in iter_relevant_parts(doc, path): + feedback: list[Any] = [] + exit_stack.enter_context( + WrappedMember( + part, member_name, + feedback, access_type=access_type) + ) + out_list.append(feedback) + yield out_list From 5aee12d00249a2d028befd1fc6d93bc86864fba5 Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Thu, 16 Jul 2026 16:12:25 +0100 Subject: [PATCH 15/41] Add doc strings to various methods --- medcat-v2/medcat/components/contracting.py | 51 +++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/medcat-v2/medcat/components/contracting.py b/medcat-v2/medcat/components/contracting.py index 2074f6756..35c3c17e8 100644 --- a/medcat-v2/medcat/components/contracting.py +++ b/medcat-v2/medcat/components/contracting.py @@ -18,7 +18,26 @@ def verify_part( access_type: AccessType, raise_on_violation: bool = True, min_feedbacks: int = 0, -): +) -> list[str]: + """Verify the parts for this text. + + Args: + text (str): The text to use. + doc_getter (Callable[[str], MutableDocument]): The document getter. + component (BaseComponent): The component to check. + paths (list[str]): The paths to check. + access_type (AccessType): The type of access to check. + raise_on_violation (bool): Whether to raise on a violation. + Defaults to True. + min_feedbacks (int): The minimum number of feedbacks expected. + Defaults to 0. + + Raises: + ContractViolation: If there are violations and instructed to raise. + + Returns: + list[str]: The list of violations, if any. + """ violations: list[str] = [] for path in paths: doc = doc_getter(text) @@ -60,6 +79,21 @@ def verify_needs( raise_on_violation: bool = True, min_feedbacks: int = 0, ) -> list[str]: + """Verify the needs portion of a contract. + + Args: + text (str): The text to use. + doc_getter (Callable[[str], MutableDocument]): The document getter. + component (BaseComponent): The component to check. + contract (ComponentContract): The contract to check. + raise_on_violation (bool): Whether to raise on violations. + Defaults to True. + min_feedbacks (int,): The minimum number of feedbacks expected. + Defaults to 0. + + Returns: + list[str]: The list of violations, if any. + """ return verify_part( text, doc_getter, component, list(contract.needs), AccessType.READ, raise_on_violation=raise_on_violation, @@ -75,6 +109,21 @@ def verify_must_provide( raise_on_violation: bool = True, min_feedbacks: int = 0, ) -> list[str]: + """Verify the must-provide portion of a contract. + + Args: + text (str): The text to use. + doc_getter (Callable[[str], MutableDocument]): The document getter. + component (BaseComponent): The component to check. + contract (ComponentContract): The contract to check. + raise_on_violation (bool): Whether to raise on violations. + Defaults to True. + min_feedbacks (int,): The minimum number of feedbacks expected. + Defaults to 0. + + Returns: + list[str]: The list of violations, if any. + """ return verify_part( text, doc_getter, component, list(contract.must_provide), AccessType.WRITE, raise_on_violation=raise_on_violation, From 639d998b4533ea62b86630b9840c0f44bac42725 Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Thu, 16 Jul 2026 16:14:43 +0100 Subject: [PATCH 16/41] Add type ignoring comments where needed --- medcat-v2/medcat/components/contracting_utils.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/medcat-v2/medcat/components/contracting_utils.py b/medcat-v2/medcat/components/contracting_utils.py index b5366fe0a..d511671a7 100644 --- a/medcat-v2/medcat/components/contracting_utils.py +++ b/medcat-v2/medcat/components/contracting_utils.py @@ -109,9 +109,11 @@ def __setattr__(self, name: str, value: Any): per_instance_spied[self].append((str(old), str(value))) if access_type == AccessType.READ: - token_cls.__getattribute__ = __getattribute__ + # NOTE: this should be fine, but mypy complains due to self + token_cls.__getattribute__ = __getattribute__ # type: ignore elif access_type == AccessType.WRITE: - token_cls.__setattr__ = __setattr__ + # NOTE: this should be fine, but mypy complains due to self + token_cls.__setattr__ = __setattr__ # type: ignore else: raise ValueError(f"Unknown access type: {access_type}") try: @@ -123,7 +125,8 @@ def __setattr__(self, name: str, value: Any): token_cls.__getattribute__ = prev_getattr if prev_setattr is _SENTINEL: - token_cls.__setattr__ = object.__setattr__ + # NOTE: this should be fine, but mypy complains due to self + token_cls.__setattr__ = object.__setattr__ # type: ignore else: token_cls.__setattr__ = prev_setattr From da62a7d7c9dd9db32348849e317eb30f35ace1e7 Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Thu, 16 Jul 2026 16:15:34 +0100 Subject: [PATCH 17/41] Add initial contracting tests --- medcat-v2/tests/components/test_contracting.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/medcat-v2/tests/components/test_contracting.py b/medcat-v2/tests/components/test_contracting.py index ed176b8ef..7ca7a458f 100644 --- a/medcat-v2/tests/components/test_contracting.py +++ b/medcat-v2/tests/components/test_contracting.py @@ -10,7 +10,6 @@ from tests import UNPACKED_EXAMPLE_MODEL_PACK_PATH - class TestContractingNer(TestCase): comp_type = CoreComponentType.ner comp_cls = NER @@ -42,7 +41,6 @@ def test_contract_held(self): min_feedbacks_provide=self.min_feedbacks_provide, ) self.assertFalse(violations, "Expected not violations") - # self.assertTrue(self.comp(self.component_prep(self.text))) class TestContractingLinker(TestContractingNer): From fff249a065ced3644feb0e422030bb9a3d7047cc Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Thu, 16 Jul 2026 16:15:55 +0100 Subject: [PATCH 18/41] Fix typo --- medcat-v2/tests/components/test_contracting.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/medcat-v2/tests/components/test_contracting.py b/medcat-v2/tests/components/test_contracting.py index 7ca7a458f..71cce6ed2 100644 --- a/medcat-v2/tests/components/test_contracting.py +++ b/medcat-v2/tests/components/test_contracting.py @@ -40,7 +40,7 @@ def test_contract_held(self): min_feedbacks_need=self.min_feedbacks_need, min_feedbacks_provide=self.min_feedbacks_provide, ) - self.assertFalse(violations, "Expected not violations") + self.assertFalse(violations, "Expected no violations") class TestContractingLinker(TestContractingNer): From b58e52bdeb89eec8b8d2baff6a8a516cec0bb9a6 Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Thu, 16 Jul 2026 16:28:01 +0100 Subject: [PATCH 19/41] Yield a live container rather than converting where appropriate --- medcat-v2/medcat/components/contracting_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/medcat-v2/medcat/components/contracting_utils.py b/medcat-v2/medcat/components/contracting_utils.py index d511671a7..cde9ed741 100644 --- a/medcat-v2/medcat/components/contracting_utils.py +++ b/medcat-v2/medcat/components/contracting_utils.py @@ -160,7 +160,7 @@ def wrap_relevant_token_cls( with spy_token_class( tkn_cls, attr_name, access_type ) as per_instance_spied: - yield list(per_instance_spied.values()) + yield per_instance_spied.values() @contextmanager From 13108123b6f7809f2c6fcdcec4b54fca87552bea Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Thu, 16 Jul 2026 16:29:50 +0100 Subject: [PATCH 20/41] Amend tests to assert non-trivial number of needs --- medcat-v2/tests/components/test_contracting.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/medcat-v2/tests/components/test_contracting.py b/medcat-v2/tests/components/test_contracting.py index 71cce6ed2..1ff4155b5 100644 --- a/medcat-v2/tests/components/test_contracting.py +++ b/medcat-v2/tests/components/test_contracting.py @@ -14,9 +14,11 @@ class TestContractingNer(TestCase): comp_type = CoreComponentType.ner comp_cls = NER args: Callable[[], list] = list - min_feedbacks_need = 0 + min_feedbacks_need = 23 min_feedbacks_provide = 1 - text = "John had been diagnosed with acute Kidney Failure the week before." + text = """ + John had been diagnosed with acute Kidney - Failure the week before. + """ @classmethod def component_prep(cls, text: str) -> MutableDocument: @@ -40,6 +42,8 @@ def test_contract_held(self): min_feedbacks_need=self.min_feedbacks_need, min_feedbacks_provide=self.min_feedbacks_provide, ) + doc = self.component_prep(self.text) + print("PIPE man", doc, "->", [tkn for tkn in doc if tkn.to_skip]) self.assertFalse(violations, "Expected no violations") From 8ee0176bdc11e203ed0526fd1d07ec5223979ad7 Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Thu, 16 Jul 2026 16:35:49 +0100 Subject: [PATCH 21/41] Add comment to tests --- medcat-v2/tests/components/test_contracting.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/medcat-v2/tests/components/test_contracting.py b/medcat-v2/tests/components/test_contracting.py index 1ff4155b5..871f15456 100644 --- a/medcat-v2/tests/components/test_contracting.py +++ b/medcat-v2/tests/components/test_contracting.py @@ -14,7 +14,16 @@ class TestContractingNer(TestCase): comp_type = CoreComponentType.ner comp_cls = NER args: Callable[[], list] = list + # NOTE: these depend on the text and component + # e.g for NER, this is called once per token to check + # if they're skipped + # e.g for linker, this counts the number of time + # doc.ner_ents is read min_feedbacks_need = 23 + # NOTE: e.g for NER, this counts the setting of detected_name + # for each token in doc.ner_ents + # e.g for linker, this counts the setting of + # cui and context_similarity for each token in doc.linked_ents min_feedbacks_provide = 1 text = """ John had been diagnosed with acute Kidney - Failure the week before. From 0ed14902d0a8bc9e1e0b26187de854f9d39074f5 Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Thu, 16 Jul 2026 16:45:56 +0100 Subject: [PATCH 22/41] Infer number of min needed feedback automatically --- .../tests/components/test_contracting.py | 25 +++++++------------ 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/medcat-v2/tests/components/test_contracting.py b/medcat-v2/tests/components/test_contracting.py index 871f15456..27b6cafed 100644 --- a/medcat-v2/tests/components/test_contracting.py +++ b/medcat-v2/tests/components/test_contracting.py @@ -14,17 +14,6 @@ class TestContractingNer(TestCase): comp_type = CoreComponentType.ner comp_cls = NER args: Callable[[], list] = list - # NOTE: these depend on the text and component - # e.g for NER, this is called once per token to check - # if they're skipped - # e.g for linker, this counts the number of time - # doc.ner_ents is read - min_feedbacks_need = 23 - # NOTE: e.g for NER, this counts the setting of detected_name - # for each token in doc.ner_ents - # e.g for linker, this counts the setting of - # cui and context_similarity for each token in doc.linked_ents - min_feedbacks_provide = 1 text = """ John had been diagnosed with acute Kidney - Failure the week before. """ @@ -45,21 +34,25 @@ def setUpClass(cls) -> None: return super().setUpClass() def test_contract_held(self): + min_feedbacks_need = ( + len(list(self.component_prep(self.text))) + if self.comp_type is CoreComponentType.ner else + 1 + ) + # for NER / linker it's just setting once + min_feedbacks_provide = 1 violations = contracting.verify_contract( self.text, self.component_prep, self.comp, self.comp_type.value, raise_on_violation=False, - min_feedbacks_need=self.min_feedbacks_need, - min_feedbacks_provide=self.min_feedbacks_provide, + min_feedbacks_need=min_feedbacks_need, + min_feedbacks_provide=min_feedbacks_provide, ) - doc = self.component_prep(self.text) - print("PIPE man", doc, "->", [tkn for tkn in doc if tkn.to_skip]) self.assertFalse(violations, "Expected no violations") class TestContractingLinker(TestContractingNer): comp_type = CoreComponentType.linking comp_cls = Linker - min_feedbacks_need = 1 @classmethod def setUpClass(cls) -> None: From 990c0b8229bf40efd69a5eba4cc50c36cb5b09f2 Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Thu, 16 Jul 2026 17:08:17 +0100 Subject: [PATCH 23/41] Add a contracting testing module --- .../medcat/components/contracting_testing.py | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 medcat-v2/medcat/components/contracting_testing.py diff --git a/medcat-v2/medcat/components/contracting_testing.py b/medcat-v2/medcat/components/contracting_testing.py new file mode 100644 index 000000000..6f2f8ea3c --- /dev/null +++ b/medcat-v2/medcat/components/contracting_testing.py @@ -0,0 +1,84 @@ +from typing import Optional + +from medcat.cat import CAT +from medcat.tokenizing.tokens import MutableDocument +from medcat.components.base import CoreComponentType +from medcat.components.types import CoreComponent +from medcat.components.contracting import verify_contract + + +_DEFAULT_CONTRACT_TEXT = """ +John had been diagnosed with acute Kidney - Failure the week before. +""" + + +class ContractViolationError(ValueError): + pass + + +def assert_single_component_holds( + model: CAT, + component: CoreComponent, + text: str = _DEFAULT_CONTRACT_TEXT, +): + """Assert a specific component's contract holds. + + Args: + model (CAT): The model with the specific component. + component (CoreComponent): The component under test. + text (str): The text to use for the check. + Defaults to _DEFAULT_CONTRACT_TEXT. + """ + component_type = component.get_type() + + def prep(t: str) -> MutableDocument: + return model.pipe.pipe_until(t, component_type) + + contract = component_type.value + min_feedbacks_need = ( + len(list(prep(text))) if component_type is CoreComponentType.ner else 1 + ) + violations = verify_contract( + text, prep, component, contract, + raise_on_violation=False, + min_feedbacks_need=min_feedbacks_need, + min_feedbacks_provide=1, + ) + if violations: + raise ContractViolationError(component_type, violations) + + +def assert_component_contracts( + model: CAT, + text: str = _DEFAULT_CONTRACT_TEXT, + to_check: Optional[list[CoreComponentType]] = None +): + """Verify that all components upholds its MedCAT contract. + + Intended for use in tests by external component implementers. + Raises ContractViolationError if the contract is not upheld. + + Example: + + def test_my_ner_contract(self): + cat = create_model_with_my_component() + assert_component_contract(cat) + + Args: + model (CAT): The model pack to use. This needs to refer to a model that + is able to NER and link at least 1 entity in the provided text. + This model needs to already have the relevant component(s) to be + checked. + to_check (Optional[list[CoreComponentType]]): The core component types + to check. Defaults to NER and linking. + text (str): The text to use for the check. + Defaults to _DEFAULT_CONTRACT_TEXT. + + Raises: + ContractViolationError: If there are any violations found. + """ + if to_check is None: + to_check = [CoreComponentType.ner, CoreComponentType.linking] + for cct in to_check: + cur_comp = model.pipe.get_component(cct) + assert_single_component_holds(model, cur_comp, text) From 727406a18d12af41dc05ba237786e8f1b9ed5060 Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Thu, 16 Jul 2026 17:10:03 +0100 Subject: [PATCH 24/41] Specify default value in module rather than within method --- medcat-v2/medcat/components/contracting_testing.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/medcat-v2/medcat/components/contracting_testing.py b/medcat-v2/medcat/components/contracting_testing.py index 6f2f8ea3c..eb7107229 100644 --- a/medcat-v2/medcat/components/contracting_testing.py +++ b/medcat-v2/medcat/components/contracting_testing.py @@ -10,6 +10,8 @@ _DEFAULT_CONTRACT_TEXT = """ John had been diagnosed with acute Kidney - Failure the week before. """ +_DEFAULT_COMP_TYPES_TO_CHECK = [ + CoreComponentType.ner, CoreComponentType.linking] class ContractViolationError(ValueError): @@ -78,7 +80,7 @@ def test_my_ner_contract(self): ContractViolationError: If there are any violations found. """ if to_check is None: - to_check = [CoreComponentType.ner, CoreComponentType.linking] + to_check = _DEFAULT_COMP_TYPES_TO_CHECK for cct in to_check: cur_comp = model.pipe.get_component(cct) assert_single_component_holds(model, cur_comp, text) From f60ff01ea47392ba5c5da934e1582c400fdcfb74 Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Thu, 16 Jul 2026 17:14:58 +0100 Subject: [PATCH 25/41] Avoid testing min feedbacks 0 (i.e if nothing to tokenize) --- medcat-v2/medcat/components/contracting_testing.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/medcat-v2/medcat/components/contracting_testing.py b/medcat-v2/medcat/components/contracting_testing.py index eb7107229..76c11bb46 100644 --- a/medcat-v2/medcat/components/contracting_testing.py +++ b/medcat-v2/medcat/components/contracting_testing.py @@ -40,6 +40,11 @@ def prep(t: str) -> MutableDocument: min_feedbacks_need = ( len(list(prep(text))) if component_type is CoreComponentType.ner else 1 ) + if not min_feedbacks_need: + # NOTE: this would normally happen with NER if/when there's no tokens + raise ContractViolationError( + "Cannot check for feedback needs if minimum is 0 " + f"for {component.full_name}") violations = verify_contract( text, prep, component, contract, raise_on_violation=False, From 6d8507b6be8d8f6b09d1e9148c23ed6f181838c6 Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Thu, 16 Jul 2026 17:15:51 +0100 Subject: [PATCH 26/41] Simplify contracting tests --- .../tests/components/test_contracting.py | 54 ++----------------- 1 file changed, 4 insertions(+), 50 deletions(-) diff --git a/medcat-v2/tests/components/test_contracting.py b/medcat-v2/tests/components/test_contracting.py index 27b6cafed..afeefa883 100644 --- a/medcat-v2/tests/components/test_contracting.py +++ b/medcat-v2/tests/components/test_contracting.py @@ -1,61 +1,15 @@ from unittest import TestCase -from typing import Callable from medcat.cat import CAT -from medcat.components import contracting -from medcat.components.base import CoreComponentType -from medcat.components.ner.vocab_based_ner import NER -from medcat.components.linking.context_based_linker import Linker -from medcat.tokenizing.tokens import MutableDocument +from medcat.components import contracting_testing from tests import UNPACKED_EXAMPLE_MODEL_PACK_PATH -class TestContractingNer(TestCase): - comp_type = CoreComponentType.ner - comp_cls = NER - args: Callable[[], list] = list - text = """ - John had been diagnosed with acute Kidney - Failure the week before. - """ - - @classmethod - def component_prep(cls, text: str) -> MutableDocument: - return cls._model.pipe.pipe_until(text, cls.comp_type) +class TestContractingForModel(TestCase): @classmethod def setUpClass(cls) -> None: cls._model = CAT.load_model_pack(UNPACKED_EXAMPLE_MODEL_PACK_PATH) - cls.tokenizer = cls._model.pipe.tokenizer - cls.cdb = cls._model.cdb - if not cls.args(): - cls.comp = cls.comp_cls(cls.tokenizer, cls.cdb) - else: - cls.comp = cls.comp_cls(*cls.args()) - return super().setUpClass() - - def test_contract_held(self): - min_feedbacks_need = ( - len(list(self.component_prep(self.text))) - if self.comp_type is CoreComponentType.ner else - 1 - ) - # for NER / linker it's just setting once - min_feedbacks_provide = 1 - violations = contracting.verify_contract( - self.text, self.component_prep, self.comp, self.comp_type.value, - raise_on_violation=False, - min_feedbacks_need=min_feedbacks_need, - min_feedbacks_provide=min_feedbacks_provide, - ) - self.assertFalse(violations, "Expected no violations") - -class TestContractingLinker(TestContractingNer): - comp_type = CoreComponentType.linking - comp_cls = Linker - - @classmethod - def setUpClass(cls) -> None: - cls.args = lambda: [ - cls.cdb, cls._model.vocab, cls._model.config] - return super().setUpClass() + def test_contracts_held(self): + contracting_testing.assert_component_contracts(self._model) From f6566d318ba35d7c57deaa1f5d179a5eabfdd66d Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Thu, 16 Jul 2026 17:20:41 +0100 Subject: [PATCH 27/41] Add extra test for individual components --- medcat-v2/tests/components/test_contracting.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/medcat-v2/tests/components/test_contracting.py b/medcat-v2/tests/components/test_contracting.py index afeefa883..225779a30 100644 --- a/medcat-v2/tests/components/test_contracting.py +++ b/medcat-v2/tests/components/test_contracting.py @@ -2,6 +2,7 @@ from medcat.cat import CAT from medcat.components import contracting_testing +from medcat.components.base import CoreComponentType from tests import UNPACKED_EXAMPLE_MODEL_PACK_PATH @@ -11,5 +12,10 @@ class TestContractingForModel(TestCase): def setUpClass(cls) -> None: cls._model = CAT.load_model_pack(UNPACKED_EXAMPLE_MODEL_PACK_PATH) - def test_contracts_held(self): + def test_all_contracts_hold(self): contracting_testing.assert_component_contracts(self._model) + + def test_individual_contracts_hold(self): + for ct in [CoreComponentType.ner, CoreComponentType.linking]: + comp = self._model.pipe.get_component(ct) + contracting_testing.assert_single_component_holds(self._model, comp) From 6f15e242099180933e8a837952dbc8cb37551257 Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Thu, 16 Jul 2026 17:24:49 +0100 Subject: [PATCH 28/41] Add example to single-component check --- medcat-v2/medcat/components/contracting_testing.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/medcat-v2/medcat/components/contracting_testing.py b/medcat-v2/medcat/components/contracting_testing.py index 76c11bb46..949e71c03 100644 --- a/medcat-v2/medcat/components/contracting_testing.py +++ b/medcat-v2/medcat/components/contracting_testing.py @@ -25,6 +25,13 @@ def assert_single_component_holds( ): """Assert a specific component's contract holds. + Example: + + def test_my_ner_contract(self): + cat = create_model_with_my_ner() + my_ner = cat.pipe.get_component(CoreComponentType.ner) + assert_single_component_holds(cat, my_ner) + Args: model (CAT): The model with the specific component. component (CoreComponent): The component under test. From 70ee5d4dad5a762101f4d23ac4de7cfbaebbd7a1 Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Thu, 16 Jul 2026 17:26:43 +0100 Subject: [PATCH 29/41] Make a better exception be raised if/when contracts do not hold --- medcat-v2/medcat/components/contracting_testing.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/medcat-v2/medcat/components/contracting_testing.py b/medcat-v2/medcat/components/contracting_testing.py index 949e71c03..f960115a0 100644 --- a/medcat-v2/medcat/components/contracting_testing.py +++ b/medcat-v2/medcat/components/contracting_testing.py @@ -15,7 +15,13 @@ class ContractViolationError(ValueError): - pass + + def __init__(self, component_type: CoreComponentType, violations: list): + self.component_type = component_type + self.violations = violations + super().__init__( + f"Contract violations for {component_type.name}: {violations}" + ) def assert_single_component_holds( From 0523f0b735399e00847b76b5b5257ef93f8bb31e Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Thu, 16 Jul 2026 18:23:45 +0100 Subject: [PATCH 30/41] Fix exception creation --- medcat-v2/medcat/components/contracting_testing.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/medcat-v2/medcat/components/contracting_testing.py b/medcat-v2/medcat/components/contracting_testing.py index f960115a0..de5baa8fd 100644 --- a/medcat-v2/medcat/components/contracting_testing.py +++ b/medcat-v2/medcat/components/contracting_testing.py @@ -56,8 +56,9 @@ def prep(t: str) -> MutableDocument: if not min_feedbacks_need: # NOTE: this would normally happen with NER if/when there's no tokens raise ContractViolationError( - "Cannot check for feedback needs if minimum is 0 " - f"for {component.full_name}") + component_type, + ["Cannot check for feedback needs if minimum is 0 " + f"for {component.full_name}", ]) violations = verify_contract( text, prep, component, contract, raise_on_violation=False, From 23ea94e830ca2311229f046a02f32a9233ccf619 Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Thu, 16 Jul 2026 18:54:16 +0100 Subject: [PATCH 31/41] Add collection contracting tp contract checks --- medcat-v2/medcat/components/contracting.py | 71 ++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/medcat-v2/medcat/components/contracting.py b/medcat-v2/medcat/components/contracting.py index 35c3c17e8..a44b5f0fa 100644 --- a/medcat-v2/medcat/components/contracting.py +++ b/medcat-v2/medcat/components/contracting.py @@ -131,6 +131,71 @@ def verify_must_provide( ) +def verify_collections_contracts( + text: str, + doc_getter: Callable[[str], MutableDocument], + component: BaseComponent, + contract: ComponentContract, + raise_on_violation: bool = True, + min_feedbacks: int = 0, +) -> list[str]: + """Verify the collection contracts portion of a contract. + + Args: + text (str): The text to use. + doc_getter (Callable[[str], MutableDocument]): The document getter. + component (BaseComponent): The component to check. + contract (ComponentContract): The contract to check. + raise_on_violation (bool): Whether to raise on violations. + Defaults to True. + min_feedbacks (int): The minimum number of feedbacks expected. + Defaults to 0. + + Returns: + list[str]: The list of violations, if any. + """ + violations: list[str] = [] + if not contract.collection_contracts: + return violations + doc = doc_getter(text) + doc = component(doc) + for cc in contract.collection_contracts: + if not cc.field.startswith("doc."): + violations.append( + f"Collection contract field '{cc.field}' is not a doc-level " + f"field — only doc.* fields are currently supported") + continue + attr = cc.field.split(".", 1)[1] + try: + collection = getattr(doc, attr) + except AttributeError: + violations.append( + f"Component {component.full_name} did not provide " + f"collection '{cc.field}' at all") + continue + items = list(collection) + if len(items) < min_feedbacks: + violations.append( + f"Collection '{cc.field}' has too few items " + f"({len(items)} with minimum {min_feedbacks})") + for i, item in enumerate(items): + for field in cc.must_provide: + try: + val = getattr(item, field) + except AttributeError: + violations.append( + f"Item {i} in '{cc.field}' is missing " + f"required field '{field}'") + continue + if not val: + violations.append( + f"Item {i} in '{cc.field}' has falsy value " + f"for required field '{field}' (got {val!r})") + if violations and raise_on_violation: + raise ContractViolation("\n".join(violations)) + return violations + + def verify_contract( text: str, doc_getter: Callable[[str], MutableDocument], @@ -139,6 +204,7 @@ def verify_contract( raise_on_violation: bool = True, min_feedbacks_need: int = 0, min_feedbacks_provide: int = 0, + min_feedbacks_contracts: int = 0, ) -> list[str]: """ Verify a ComponentContract against a document before/after a component ran. @@ -156,6 +222,11 @@ def verify_contract( text, doc_getter, component, contract, raise_on_violation=False, min_feedbacks=min_feedbacks_provide, ) + # verify collections contracts + violations += verify_collections_contracts( + text, doc_getter, component, contract, raise_on_violation=False, + min_feedbacks=min_feedbacks_contracts, + ) if violations and raise_on_violation: raise ContractViolation("\n".join(violations)) From 334ec1fbfd95a1bb2e24606bea15bc7c2aaa3b14 Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Thu, 16 Jul 2026 18:54:34 +0100 Subject: [PATCH 32/41] Expect at least one entity in output --- medcat-v2/medcat/components/contracting_testing.py | 1 + 1 file changed, 1 insertion(+) diff --git a/medcat-v2/medcat/components/contracting_testing.py b/medcat-v2/medcat/components/contracting_testing.py index de5baa8fd..3e6179b3c 100644 --- a/medcat-v2/medcat/components/contracting_testing.py +++ b/medcat-v2/medcat/components/contracting_testing.py @@ -64,6 +64,7 @@ def prep(t: str) -> MutableDocument: raise_on_violation=False, min_feedbacks_need=min_feedbacks_need, min_feedbacks_provide=1, + min_feedbacks_contracts=1, ) if violations: raise ContractViolationError(component_type, violations) From 47aab1bd38115b1daa222b22a3a165d95394d092 Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Thu, 16 Jul 2026 18:55:40 +0100 Subject: [PATCH 33/41] Add a few comments to testing utils --- medcat-v2/medcat/components/contracting_testing.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/medcat-v2/medcat/components/contracting_testing.py b/medcat-v2/medcat/components/contracting_testing.py index 3e6179b3c..614c1b574 100644 --- a/medcat-v2/medcat/components/contracting_testing.py +++ b/medcat-v2/medcat/components/contracting_testing.py @@ -63,7 +63,10 @@ def prep(t: str) -> MutableDocument: text, prep, component, contract, raise_on_violation=False, min_feedbacks_need=min_feedbacks_need, + # NOTE: this means that the collection (ner_ents or linked_ents) is + # written to at least once min_feedbacks_provide=1, + # NOTE: this means we expect at least 1 entity in output min_feedbacks_contracts=1, ) if violations: From 8671d2448489c247e5a4455f4d779ff51370240e Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Thu, 16 Jul 2026 18:56:02 +0100 Subject: [PATCH 34/41] Add a few tests for the contracting testing module --- .../components/test_contracting_testing.py | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 medcat-v2/tests/components/test_contracting_testing.py diff --git a/medcat-v2/tests/components/test_contracting_testing.py b/medcat-v2/tests/components/test_contracting_testing.py new file mode 100644 index 000000000..df96ae019 --- /dev/null +++ b/medcat-v2/tests/components/test_contracting_testing.py @@ -0,0 +1,39 @@ +from unittest import TestCase +from contextlib import contextmanager + +from medcat.cat import CAT +from medcat.components import contracting_testing +from medcat.utils.cdb_state import captured_state_cdb +from tests import UNPACKED_EXAMPLE_MODEL_PACK_PATH + + +class ContractingTestingTests(TestCase): + + @classmethod + def setUpClass(cls) -> None: + cls._model = CAT.load_model_pack(UNPACKED_EXAMPLE_MODEL_PACK_PATH) + + @contextmanager + def empty_cdb(self): + with captured_state_cdb(self._model.cdb): + self._model.cdb.name2info.clear() + self._model.cdb.cui2info.clear() + yield + + def test_contracting_normally_passes(self): + contracting_testing.assert_component_contracts(self._model) + + def test_contracting_fails_with_empty_cdb(self): + with self.empty_cdb(): + with self.assertRaises(contracting_testing.ContractViolationError): + contracting_testing.assert_component_contracts(self._model) + + def test_contracting_fails_with_no_entity(self): + with self.assertRaises(contracting_testing.ContractViolationError): + contracting_testing.assert_component_contracts( + self._model, "Text with no entities") + + def test_contracting_fails_with_no_tokens(self): + with self.assertRaises(contracting_testing.ContractViolationError): + contracting_testing.assert_component_contracts( + self._model, "") From 61a05a36b4c0ee17dc929581f497c2e39a596400 Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Thu, 16 Jul 2026 19:01:35 +0100 Subject: [PATCH 35/41] Add a comment on what collection contract check does --- medcat-v2/medcat/components/contracting.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/medcat-v2/medcat/components/contracting.py b/medcat-v2/medcat/components/contracting.py index a44b5f0fa..8afe55e40 100644 --- a/medcat-v2/medcat/components/contracting.py +++ b/medcat-v2/medcat/components/contracting.py @@ -141,6 +141,13 @@ def verify_collections_contracts( ) -> list[str]: """Verify the collection contracts portion of a contract. + This method only really checks the length of the collection + and that each item in there has a truthy value for each required + field. The expectation is that the write action (for the collection) + is checked by other parts. And because these entities may be created + in order to put them in the lists (i.e for NER) without the data filled + in, it's fair to assume that if the data exists, it was filled in. + Args: text (str): The text to use. doc_getter (Callable[[str], MutableDocument]): The document getter. From 2c685c5df18986b46f6b90c5c023b8cf57d7cf2f Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Thu, 16 Jul 2026 19:05:28 +0100 Subject: [PATCH 36/41] Rename method in doc string example --- medcat-v2/medcat/components/contracting_testing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/medcat-v2/medcat/components/contracting_testing.py b/medcat-v2/medcat/components/contracting_testing.py index 614c1b574..36a52e480 100644 --- a/medcat-v2/medcat/components/contracting_testing.py +++ b/medcat-v2/medcat/components/contracting_testing.py @@ -85,7 +85,7 @@ def assert_component_contracts( Example: - def test_my_ner_contract(self): + def test_my_model_contract(self): cat = create_model_with_my_component() assert_component_contract(cat) From c221dd9ec53375493c892e8bc8c109fb48b56de4 Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Thu, 16 Jul 2026 19:07:33 +0100 Subject: [PATCH 37/41] Fix typo in doc string --- medcat-v2/medcat/components/contracting_testing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/medcat-v2/medcat/components/contracting_testing.py b/medcat-v2/medcat/components/contracting_testing.py index 36a52e480..9cfffd75d 100644 --- a/medcat-v2/medcat/components/contracting_testing.py +++ b/medcat-v2/medcat/components/contracting_testing.py @@ -87,7 +87,7 @@ def assert_component_contracts( def test_my_model_contract(self): cat = create_model_with_my_component() - assert_component_contract(cat) + assert_component_contracts(cat) Args: model (CAT): The model pack to use. This needs to refer to a model that From 83189ceca501dd914f3619b7692e30b62f523202 Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Mon, 20 Jul 2026 15:48:14 +0100 Subject: [PATCH 38/41] Add mention to README --- medcat-v2/README.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/medcat-v2/README.md b/medcat-v2/README.md index a8248c195..244b1a4c3 100644 --- a/medcat-v2/README.md +++ b/medcat-v2/README.md @@ -151,6 +151,29 @@ See [Architecture Documentation](docs/architecture.md) for detailed information. ## Tutorials A guide on how to use MedCAT v2 is available at on the medcat documentation page on [docs.cogstack.org](https://docs.cogstack.org) +## Contributing + +Please follow the [Contribution Guidelines](../CONTRIBUTING.md). + +When writing your own component (NER or linker), it is recommended making sure they follow the contracts for these components. +
+Example test for custom components in model pack + +```python +from unittest import TestCase +from medcat.components.contracting_testing import assert_component_contracts +# implement create_model_with_my_component +class MyComponentTest(TestCase): + def test_my_model_contract(self): + # create or load a model with your custom component(s) + # NOTE: This would (generally) need to be able to NER / link 1 entity in the example text + # The test time models in medcat would be sufficient + cat = create_model_with_my_component() + assert_component_contracts(cat) +``` +
+ + ## Acknowledgements Entity extraction was trained on [MedMentions](https://github.com/chanzuckerberg/MedMentions) In total it has ~ 35K entites from UMLS From cd920b6baa552283b1a60ece0f9dd41ee8e67347 Mon Sep 17 00:00:00 2001 From: mart-r Date: Thu, 3 Sep 2026 11:08:56 +0100 Subject: [PATCH 39/41] Remove merge artefact --- medcat-v2/README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/medcat-v2/README.md b/medcat-v2/README.md index c8d74b321..886b0f1f8 100644 --- a/medcat-v2/README.md +++ b/medcat-v2/README.md @@ -1,6 +1,5 @@ # MedCAT v2 -<<<<<<< HEAD MedCAT can be used to extract information from Electronic Health Records (EHRs) and link it to biomedical ontologies like SNOMED-CT, UMLS, or HPO (and potentially other ontologies). Original paper for v1 on [arXiv](https://arxiv.org/abs/2010.01165). From 7714668d856ced346b860d020ce1c3b96e73decb Mon Sep 17 00:00:00 2001 From: mart-r Date: Thu, 3 Sep 2026 11:11:48 +0100 Subject: [PATCH 40/41] Remove only README due to merge issues + update/fix link in it --- medcat-v2/README.md | 218 +------------------------------------------- 1 file changed, 1 insertion(+), 217 deletions(-) diff --git a/medcat-v2/README.md b/medcat-v2/README.md index 886b0f1f8..ee4bf9e54 100644 --- a/medcat-v2/README.md +++ b/medcat-v2/README.md @@ -1,217 +1 @@ -# MedCAT v2 - -MedCAT can be used to extract information from Electronic Health Records (EHRs) and link it to biomedical ontologies like SNOMED-CT, UMLS, or HPO (and potentially other ontologies). -Original paper for v1 on [arXiv](https://arxiv.org/abs/2010.01165). - -## Why MedCAT v2? - -MedCAT v2 is a comprehensive refactor designed to improve modularity, flexibility, and maintainability. The core library is now lightweight, with optional extras (spaCy tokenization, MetaCAT, DeID, RelCAT) available as separate installable features—allowing you to install only what you need. This modular approach reduces dependencies, enables smaller installs, and provides better separation of concerns. Additionally, v2 reduces internal coupling with spaCy, allowing for alternative tokenizers and greater extensibility. The new architecture makes it easier to create custom components and addons, while improving code maintainability and preparing the foundation for future enhancements. For most users, single-threaded inference APIs remain unchanged, ensuring a smooth transition. - -**There's a number of breaking changes in MedCAT v2 compared to v1.** -When moving from v1 to v2, please refer to the [migration guide](docs/migration_guide_v2.md). -Details on breaking are outlined [here](docs/breaking_changes.md). - -[![Build Status](https://github.com/CogStack/cogstack-nlp/actions/workflows/medcat-v2_main.yml/badge.svg?branch=main)](https://github.com/CogStack/cogstack-nlp/actions/workflows/medcat-v2_main.yml/badge.svg?branch=main) -[![Documentation Status](https://readthedocs.org/projects/cogstack-nlp/badge/?version=latest)](https://readthedocs.org/projects/cogstack-nlp/badge/?version=latest) -[![Latest release](https://img.shields.io/github/v/release/CogStack/cogstack-nlp?filter=medcat/*)](https://github.com/CogStack/cogstack-nlp/releases/latest) -[![pypi Version](https://img.shields.io/pypi/v/medcat.svg?style=flat-square&logo=pypi&logoColor=white)](https://pypi.org/project/medcat/) - -**Official Docs [here](https://cogstack-nlp.readthedocs.io/)** - -**Discussion Forum [discourse](https://discourse.cogstack.org/)** - -## Available Models - -We have 2 public v2 models available: -1) SnomedCT UK Clinical edition 39.0 (Oct 2024) and UK Drug Extension 39.0 (July 2024) based model enriched with UMLS 2024AA; trained only on MIMIC-IV -2) SnomedCT UK Clinical edition 40.2 (June 2025) and UK Drug Extension 40.3 (July 2024) based model enriched with UMLS 2024AA; trained only on MIMIC-IV - -There are also a number of MedCAT v1 models available that can automatically be converted if required. - -To download any of these models, please [follow this link](https://medcat.sites.er.kcl.ac.uk/auth-callback-api) and sign in using your NIH / UMLS API key. You will then be redirected to the MedCAT model download form. Please complete this form and you will be provided a download link. - -While we encourage you use MedCAT v2 and the models in that native format, if you download an older version MedCAT v2 will be able to load it and covnert it to the format it knows. However, the loading process will be considerably longerin those cases. - -If you wish you can also convert the v1 models into the v2 format (see [tutorial](../medcat-v2-tutorials/notebooks/introductory/migration/1._Migrate_v1_model_to_v2.ipynb)). - -```python -from medcat.utils.legacy import legacy_converter -from medcat.storage.serialisers import AvailableSerialisers -old_model = '' -new_model_dir = '' -legacy_converter.do_conversion(old_model_path, new_model_dir, AvailableSerialisers.dill) -``` -OR -```bash -model_path = "models/medcat1_model_pack.zip" -new_model_folder = "models" # file in this folder -! python -m medcat.utils.legacy.legacy_converter $model_path $new_model_folder --verbose -``` - -## News -- **New public 2024 and 2025** Snomed models were uploaded and made available 7. October 2025. -- **MedCAT 2.0.0** was released 18. August 2025. - -[News pre v2.0.0](docs/v1_news.md). - -## Installation - -MedCAT v2 has its first full release -``` -pip install medcat -``` -Do note that **this installs only the core MedCAT v2**. -**It does not necessary dependencies for `spacy`-based tokenizing or MetaCATs or DeID**. -However, all of those are supported as well. -You can install them as follows: -``` -pip install "medcat[spacy]" # for spacy-based tokenizer -pip install "medcat[meta-cat]" # for MetaCAT -pip install "medcat[deid]" # for DeID models -pip install "medcat[spacy,meta-cat,deid,rel-cat,dict-ner]" # for all of the above -``` - -### Installing plugins - -MedCAT v2 supports **external plugins** that can provide new components (e.g. alternative NER models, addons, tokenizers) via Python entry points. - -- **Curated plugins**: The `medcat.plugins.catalog` module ships with a curated plugin catalog that can be updated from a remote JSON file. -- **Installer**: The `medcat.plugins.installer.PluginInstallationManager` wraps a `pip`-based installer and knows how to resolve a compatible plugin version for your current MedCAT version. -- **CLI**: You can install curated plugins directly from the command line: - -```bash -python -m medcat plugins install medcat-gliner -``` - -This will: - -- look up `medcat-gliner` in the curated catalog, -- resolve a version compatible with your installed MedCAT, -- and install it using `pip`. - -You can also: - -- pass `--dry-run` to show what would be installed without making changes: - - ```bash - python -m medcat plugins install --dry-run medcat-gliner - ``` - -- override the version/ref explicitly (e.g. when testing a branch or tag): - - ```bash - python -m medcat plugins install medcat-gliner --force-version main - ``` - -If a plugin requires authentication (for example, private Git repositories), MedCAT will log a warning and the installer will surface pip’s error messages if credentials are missing or incorrect. - -### Version / update checking - -MedCAT now has the ability to check for newer versions of itself on PyPI (or a local mirror of it). -This is so users don't get left behind too far with older versions of our software. -This is configurable by evnironmental variables so that sys admins (e.g for JupyterHub) can specify the settings they wish. -Version checks are done once a week and the results are cached. - -Below is a table of the environmental variables that govern the version checking and their defaults. - -| Variable | Default | Description | -|-----------|----------|-------------| -| **`MEDCAT_DISABLE_VERSION_CHECK`** | *(unset)* | When set to `true`, `yes` or `disable`, disables the version update check entirely. Useful for CI environments, offline setups, or deployments where external network access is restricted. | -| **`MEDCAT_PYPI_URL`** | `https://pypi.org/pypi` | Base URL used to query package metadata. Can be changed to a PyPI mirror or internal repository that exposes the `/pypi/{pkg}/json` API. | -| **`MEDCAT_MINOR_UPDATE_THRESHOLD`** | `3` | Number of newer **minor** versions (e.g. `1.4.x`, `1.5.x`) that must exist before MedCAT emits a “newer version available” log message. | -| **`MEDCAT_PATCH_UPDATE_THRESHOLD`** | `3` | Number of newer **patch** versions (e.g. `1.3.1`, `1.3.2`, `1.3.3`) on the same minor line required before emitting an informational update message. | -| **`MEDCAT_VERSION_UPDATE_LOG_LEVEL`** | `INFO` | Logging level used when reporting available newer versions (minor/patch thresholds). Accepts any valid `logging` level string (`DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`). | -| **`MEDCAT_VERSION_UPDATE_YANKED_LOG_LEVEL`** | `WARNING` | Logging level used when reporting that the current version has been **yanked** on PyPI. Accepts the same values as above. | - -## Demo - -The MedCAT v2 demo web app is available [here](https://medcat.sites.er.kcl.ac.uk/). - -## Key Concepts - -- **Components**: The building blocks of MedCAT (NER, Entity Linking, preprocessing, etc.) -- **Addons**: Components that extend the core NER+EL pipeline with additional processing stages -- **Plugins**: External packages that provide new component implementations or other functionality via entry points - -See [Architecture Documentation](docs/architecture.md) for detailed information. - -## Tutorials -A guide on how to use MedCAT v2 is available at on the medcat documentation page on [docs.cogstack.org](https://docs.cogstack.org) - -## Contributing - -Please follow the [Contribution Guidelines](../CONTRIBUTING.md). - -When writing your own component (NER or linker), it is recommended making sure they follow the contracts for these components. -
-Example test for custom components in model pack - -```python -from unittest import TestCase -from medcat.components.contracting_testing import assert_component_contracts -# implement create_model_with_my_component -class MyComponentTest(TestCase): - def test_my_model_contract(self): - # create or load a model with your custom component(s) - # NOTE: This would (generally) need to be able to NER / link 1 entity in the example text - # The test time models in medcat would be sufficient - cat = create_model_with_my_component() - assert_component_contracts(cat) -``` -
- - -## Acknowledgements -Entity extraction was trained on [MedMentions](https://github.com/chanzuckerberg/MedMentions) In total it has ~ 35K entites from UMLS - -The vocabulary was compiled from [Wiktionary](https://en.wiktionary.org/wiki/Wiktionary:Main_Page) In total ~ 800K unique words - -## Powered By -A big thank you goes to [spaCy](https://spacy.io/) and [Hugging Face](https://huggingface.co/) - who made life a million times easier. - - -## Citation -MedCAT v2 citation: -``` -@inproceedings{ratas-etal-2026-medcat, - title = "{M}ed{CAT} v2: a modular, extensible architecture for clinical named entity recognition and linking under real-world privacy and compute constraints", - author = "Ratas, Mart and - Searle, Thomas and - Sutton, Adam and - Dobson, Richard", - editor = "Demner-Fushman, Dina and - Ananiadou, Sophia and - Roberts, Kirk and - Tsujii, Junichi", - booktitle = "{B}io{NLP} 2026", - month = jul, - year = "2026", - address = "San Diego, California", - publisher = "Association for Computational Linguistics", - url = "https://aclanthology.org/2026.bionlp-1.17/", - doi = "10.18653/v1/2026.bionlp-1.17", - pages = "191--198", - ISBN = "979-8-89176-434-7", - abstract = "MedCAT is an open-source framework for clinical named entity recognition and linking (NER+L) widely used in research and healthcare settings. We present MedCAT v2, a re-engineered version designed to improve modularity, extensibility, and maintainability while preserving the core functionality and performance of previous releases. The new architecture introduces a registry-based component system and a flexible pipeline that enables easy substitution of components, integration of alternative methods, and future expansion, including support for pre-trained components across the full NER+L and contextualisation workflow. This enables systematic exploration of clinical NER+L design trade-offs by evaluating different components in the pipeline. Evaluation across multiple public datasets shows equivalent or improved performance compared to earlier versions, with reduced integration overhead and improved runtime flexibility. The framework also supports optional extensions such as meta-annotation, relation extraction, providing a unified and reproducible environment for clinical NLP in real-world settings." -} -``` -
-MedCAT v1 citation - -``` -@ARTICLE{Kraljevic2021-ln, - title="Multi-domain clinical natural language processing with {MedCAT}: The Medical Concept Annotation Toolkit", - author="Kraljevic, Zeljko and Searle, Thomas and Shek, Anthony and Roguski, Lukasz and Noor, Kawsar and Bean, Daniel and Mascio, Aurelie and Zhu, Leilei and Folarin, Amos A and Roberts, Angus and Bendayan, Rebecca and Richardson, Mark P and Stewart, Robert and Shah, Anoop D and Wong, Wai Keong and Ibrahim, Zina and Teo, James T and Dobson, Richard J B", - journal="Artif. Intell. Med.", - volume=117, - pages="102083", - month=jul, - year=2021, - issn="0933-3657", - doi="10.1016/j.artmed.2021.102083" -} -
-``` -======= -MedCAT v2 is now simply in at (../medcat)[./medcat]. ->>>>>>> main +MedCAT v2 is now simply in at [../medcat](./medcat). From be8d53a44bf7ed47b8331d72cf12bfe40c6ee1eb Mon Sep 17 00:00:00 2001 From: mart-r Date: Thu, 3 Sep 2026 11:13:29 +0100 Subject: [PATCH 41/41] Add back documentation mention to docs/index.md --- medcat/docs/index.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/medcat/docs/index.md b/medcat/docs/index.md index ad59df66c..1d25dd388 100644 --- a/medcat/docs/index.md +++ b/medcat/docs/index.md @@ -114,6 +114,28 @@ See [Architecture Documentation](docs/architecture.md) for detailed information. ## Tutorials A guide on how to use MedCAT v2 is available at on the medcat documentation page on [docs.cogstack.org](https://docs.cogstack.org) +## Contributing + +Please follow the [Contribution Guidelines](../CONTRIBUTING.md). + +When writing your own component (NER or linker), it is recommended making sure they follow the contracts for these components. +
+Example test for custom components in model pack + +```python +from unittest import TestCase +from medcat.components.contracting_testing import assert_component_contracts +# implement create_model_with_my_component +class MyComponentTest(TestCase): + def test_my_model_contract(self): + # create or load a model with your custom component(s) + # NOTE: This would (generally) need to be able to NER / link 1 entity in the example text + # The test time models in medcat would be sufficient + cat = create_model_with_my_component() + assert_component_contracts(cat) +``` +
+ ## Acknowledgements Entity extraction was trained on [MedMentions](https://github.com/chanzuckerberg/MedMentions) In total it has ~ 35K entites from UMLS